mirror of
https://github.com/Drop-OSS/drop-app.git
synced 2025-11-16 09:41:17 +10:00
Move frontend to main folder (#109)
* feat: small refactor * fix: appimage build script * fix: add NO_STRIP to AppImage build * fix: build and dev mode from refactor * fix: submodule step 1 * fix: submodules step 2
This commit is contained in:
37
main/pages/auth/code.vue
Normal file
37
main/pages/auth/code.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="min-h-full w-full flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
|
||||
Device authorization
|
||||
</h1>
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-zinc-400 max-w-md mx-auto">
|
||||
Open Drop on another one of your devices, and use your account
|
||||
dropdown to "Authorize client", and enter the code below.
|
||||
</p>
|
||||
<div
|
||||
class="mt-8 flex items-center justify-center gap-x-5 text-8xl font-bold text-zinc-100"
|
||||
>
|
||||
<span v-for="letter in code.split('')">{{ letter }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-10 flex items-center justify-center gap-x-6">
|
||||
<NuxtLink href="/auth" class="text-sm font-semibold text-blue-600"
|
||||
><span aria-hidden="true">←</span> Use a different method
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const code = await invoke<string>("auth_initiate_code");
|
||||
|
||||
definePageMeta({
|
||||
layout: "mini",
|
||||
});
|
||||
</script>
|
||||
34
main/pages/auth/failed.vue
Normal file
34
main/pages/auth/failed.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="min-h-full w-full flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<XCircleIcon class="h-12 w-12 text-red-600" aria-hidden="true" />
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
|
||||
Authentication failed
|
||||
</h1>
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-zinc-400 max-w-sm">
|
||||
Drop encountered an error while connecting to your instance. Error:
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-10 flex items-center justify-center gap-x-6">
|
||||
<NuxtLink href="/auth" class="text-sm font-semibold text-zinc-100"
|
||||
><span aria-hidden="true">←</span> Back to authentication
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XCircleIcon } from "@heroicons/vue/16/solid";
|
||||
|
||||
const route = useRoute();
|
||||
const message = route.query.error ?? "An unknown error occurred";
|
||||
|
||||
definePageMeta({
|
||||
layout: "mini",
|
||||
});
|
||||
</script>
|
||||
18
main/pages/auth/index.vue
Normal file
18
main/pages/auth/index.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<InitiateAuthModule>
|
||||
<h1
|
||||
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
||||
>
|
||||
Sign in to Drop
|
||||
</h1>
|
||||
<p class="mt-6 text-base leading-7 text-zinc-400">
|
||||
To get started, sign in to your Drop instance by clicking below.
|
||||
</p>
|
||||
</InitiateAuthModule>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: "mini",
|
||||
});
|
||||
</script>
|
||||
41
main/pages/auth/processing.vue
Normal file
41
main/pages/auth/processing.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="min-h-full w-full flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-12 h-12 text-transparent animate-spin fill-blue-600"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
<div class="mt-4 text-center sm:mt-5">
|
||||
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
|
||||
Connecting to instance...
|
||||
</h1>
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-zinc-400 max-w-sm">
|
||||
Connecting to Drop server and fetching application information...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: "mini",
|
||||
});
|
||||
</script>
|
||||
18
main/pages/auth/signedout.vue
Normal file
18
main/pages/auth/signedout.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<InitiateAuthModule>
|
||||
<h1
|
||||
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
||||
>
|
||||
You've been signed out
|
||||
</h1>
|
||||
<p class="mt-6 text-base leading-7 text-zinc-400">
|
||||
Unfortunately, you've been signed out. To sign back in, click below.
|
||||
</p>
|
||||
</InitiateAuthModule>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: "mini",
|
||||
});
|
||||
</script>
|
||||
88
main/pages/error/serverunavailable.vue
Normal file
88
main/pages/error/serverunavailable.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid min-h-full grid-cols-1 grid-rows-[1fr,auto,1fr] lg:grid-cols-[max(50%,36rem),1fr]"
|
||||
>
|
||||
<header
|
||||
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
|
||||
>
|
||||
<Logo class="h-10 w-auto sm:h-12" />
|
||||
</header>
|
||||
<main
|
||||
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
|
||||
>
|
||||
<div class="max-w-lg">
|
||||
<h1
|
||||
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
||||
>
|
||||
Server is unavailable
|
||||
</h1>
|
||||
<p class="mt-6 text-base leading-7 text-zinc-400">
|
||||
We were unable to contact your Drop instance. See if you can open it
|
||||
in your web browser, or contact your server admin for help.
|
||||
</p>
|
||||
<div class="mt-10 space-x-10">
|
||||
<button
|
||||
@click="() => retry()"
|
||||
class="inline-flex gap-x-2 items-center text-sm text-left font-semibold leading-7 text-white"
|
||||
>
|
||||
Retry <ArrowPathIcon class="w-5 h-5" />
|
||||
</button>
|
||||
<NuxtLink
|
||||
to="/setup"
|
||||
class="text-sm text-left font-semibold leading-7 text-blue-600"
|
||||
>
|
||||
Connect to different instance <span aria-hidden="true">→</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="self-end lg:col-span-2 lg:col-start-1 lg:row-start-3">
|
||||
<div class="border-t border-blue-600 bg-zinc-900 py-10">
|
||||
<nav
|
||||
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
|
||||
>
|
||||
<a href="#">Documentation</a>
|
||||
<svg
|
||||
viewBox="0 0 2 2"
|
||||
aria-hidden="true"
|
||||
class="h-0.5 w-0.5 fill-zinc-700"
|
||||
>
|
||||
<circle cx="1" cy="1" r="1" />
|
||||
</svg>
|
||||
<a href="#">Troubleshooting</a>
|
||||
<svg
|
||||
viewBox="0 0 2 2"
|
||||
aria-hidden="true"
|
||||
class="h-0.5 w-0.5 fill-zinc-700"
|
||||
>
|
||||
<circle cx="1" cy="1" r="1" />
|
||||
</svg>
|
||||
<NuxtLink to="/setup/server">Switch instance</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
<div
|
||||
class="hidden lg:relative lg:col-start-2 lg:row-start-1 lg:row-end-4 lg:block"
|
||||
>
|
||||
<img
|
||||
src="@/assets/wallpaper.jpg"
|
||||
alt=""
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowPathIcon } from "@heroicons/vue/24/outline";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
definePageMeta({
|
||||
layout: "mini",
|
||||
});
|
||||
|
||||
async function retry() {
|
||||
await invoke("retry_connect");
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
7
main/pages/index.vue
Normal file
7
main/pages/index.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template />
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: false
|
||||
})
|
||||
</script>
|
||||
52
main/pages/library.vue
Normal file
52
main/pages/library.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="flex flex-row h-full">
|
||||
<!-- Sidebar -->
|
||||
<div
|
||||
class="flex-none max-h-full overflow-y-auto w-72 bg-zinc-950/50 backdrop-blur-xl px-4 py-3 border-r border-zinc-800/50"
|
||||
>
|
||||
<LibrarySearch />
|
||||
</div>
|
||||
<div class="grow overflow-y-auto">
|
||||
<NuxtErrorBoundary>
|
||||
<NuxtPage />
|
||||
<template #error="{ error }">
|
||||
<main
|
||||
class="grid min-h-full w-full place-items-center px-6 py-24 sm:py-32 lg:px-8"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="text-base font-semibold text-blue-600">Error</p>
|
||||
<h1
|
||||
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
||||
>
|
||||
Failed to load library
|
||||
</h1>
|
||||
<p class="mt-6 text-base leading-7 text-zinc-400">
|
||||
Drop couldn't load your library: "{{ error }}".
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
</NuxtErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<style scoped>
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.list-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
650
main/pages/library/[id]/index.vue
Normal file
650
main/pages/library/[id]/index.vue
Normal file
@ -0,0 +1,650 @@
|
||||
<template>
|
||||
<div
|
||||
class="mx-auto w-full relative flex flex-col justify-center pt-72 overflow-hidden"
|
||||
>
|
||||
<div class="absolute inset-0 z-0">
|
||||
<img
|
||||
:src="bannerUrl"
|
||||
class="w-full h-[24rem] object-cover blur-sm scale-105"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-zinc-900 via-zinc-900/80 to-transparent opacity-90"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r from-zinc-900/95 via-zinc-900/80 to-transparent opacity-90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="px-8 pb-4">
|
||||
<h1
|
||||
class="text-5xl text-zinc-100 font-bold font-display drop-shadow-lg mb-8"
|
||||
>
|
||||
{{ game.mName }}
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-row gap-x-4 items-stretch mb-8">
|
||||
<!-- Do not add scale animations to this: https://stackoverflow.com/a/35683068 -->
|
||||
<GameStatusButton
|
||||
@install="() => installFlow()"
|
||||
@launch="() => launch()"
|
||||
@queue="() => queue()"
|
||||
@uninstall="() => uninstall()"
|
||||
@kill="() => kill()"
|
||||
@options="() => (configureModalOpen = true)"
|
||||
@resume="() => resumeDownload()"
|
||||
:status="status"
|
||||
/>
|
||||
<a
|
||||
:href="remoteUrl"
|
||||
target="_blank"
|
||||
type="button"
|
||||
class="transition-transform duration-300 hover:scale-105 active:scale-95 inline-flex items-center rounded-md bg-zinc-800/50 px-6 font-semibold text-white shadow-xl backdrop-blur-sm hover:bg-zinc-800/80 uppercase font-display"
|
||||
>
|
||||
<BuildingStorefrontIcon class="mr-2 size-5" aria-hidden="true" />
|
||||
Store
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="w-full bg-zinc-900 px-8 py-6">
|
||||
<div class="grid grid-cols-[2fr,1fr] gap-8">
|
||||
<div class="space-y-6">
|
||||
<div class="bg-zinc-800/50 rounded-xl p-6 backdrop-blur-sm">
|
||||
<div
|
||||
v-html="htmlDescription"
|
||||
class="prose prose-invert prose-blue overflow-y-auto custom-scrollbar max-w-none"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="bg-zinc-800/50 rounded-xl p-6 backdrop-blur-sm">
|
||||
<h2 class="text-xl font-display font-semibold text-zinc-100 mb-4">
|
||||
Game Images
|
||||
</h2>
|
||||
<div class="relative">
|
||||
<div v-if="mediaUrls.length > 0">
|
||||
<div
|
||||
class="relative aspect-video rounded-lg overflow-hidden cursor-pointer group"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
@click="fullscreenImage = mediaUrls[currentImageIndex]"
|
||||
>
|
||||
<TransitionGroup name="slide" tag="div" class="h-full">
|
||||
<img
|
||||
v-for="(url, index) in mediaUrls"
|
||||
:key="index"
|
||||
:src="url"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
v-show="index === currentImageIndex"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-between px-4 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
>
|
||||
<div class="pointer-events-auto">
|
||||
<button
|
||||
v-if="mediaUrls.length > 1"
|
||||
@click.stop="previousImage()"
|
||||
class="p-2 rounded-full bg-zinc-900/50 text-zinc-100 hover:bg-zinc-900/80 transition-all duration-300 hover:scale-110"
|
||||
>
|
||||
<ChevronLeftIcon class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="pointer-events-auto">
|
||||
<button
|
||||
v-if="mediaUrls.length > 1"
|
||||
@click.stop="nextImage()"
|
||||
class="p-2 rounded-full bg-zinc-900/50 text-zinc-100 hover:bg-zinc-900/80 transition-all duration-300 hover:scale-110"
|
||||
>
|
||||
<ChevronRightIcon class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
/>
|
||||
<div
|
||||
class="absolute bottom-4 right-4 flex items-center gap-x-2 text-white opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
>
|
||||
<ArrowsPointingOutIcon class="size-5" />
|
||||
<span class="text-sm font-medium">View Fullscreen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute -bottom-2 left-1/2 -translate-x-1/2 flex gap-x-2"
|
||||
>
|
||||
<button
|
||||
v-for="(_, index) in mediaUrls"
|
||||
:key="index"
|
||||
@click.stop="currentImageIndex = index"
|
||||
class="w-1.5 h-1.5 rounded-full transition-all"
|
||||
:class="[
|
||||
currentImageIndex === index
|
||||
? 'bg-zinc-100 scale-125'
|
||||
: 'bg-zinc-600 hover:bg-zinc-500',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="aspect-video rounded-lg overflow-hidden bg-zinc-900/50 flex flex-col items-center justify-center text-center px-4"
|
||||
>
|
||||
<PhotoIcon class="size-12 text-zinc-500 mb-2" />
|
||||
<p class="text-zinc-400 font-medium">No images available</p>
|
||||
<p class="text-zinc-500 text-sm">
|
||||
Game screenshots will appear here when available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalTemplate v-model="installFlowOpen">
|
||||
<template #default>
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<h3 class="text-base font-semibold text-zinc-100">
|
||||
Install {{ game.mName }}?
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-zinc-400">
|
||||
Drop will add {{ game.mName }} to the queue to be downloaded.
|
||||
While downloading, Drop may use up a large amount of resources,
|
||||
particularly network bandwidth and CPU utilisation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="space-y-6">
|
||||
<div v-if="versionOptions && versionOptions.length > 0">
|
||||
<Listbox as="div" v-model="installVersionIndex">
|
||||
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
|
||||
>Version</ListboxLabel
|
||||
>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
|
||||
>
|
||||
<span class="block truncate"
|
||||
>{{ versionOptions[installVersionIndex].versionName }}
|
||||
on
|
||||
{{ versionOptions[installVersionIndex].platform }}</span
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
as="template"
|
||||
v-for="(version, versionIdx) in versionOptions"
|
||||
:key="version.versionName"
|
||||
:value="versionIdx"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected
|
||||
? 'font-semibold text-zinc-100'
|
||||
: 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ version.versionName }} on
|
||||
{{ version.platform }}</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
<div v-else class="mt-1 rounded-md bg-red-600/10 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
There are no supported versions to install. Please contact your
|
||||
server admin or try again later.
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="installDirs">
|
||||
<Listbox as="div" v-model="installDir">
|
||||
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
|
||||
>Install to</ListboxLabel
|
||||
>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
|
||||
>
|
||||
<span class="block truncate">{{
|
||||
installDirs[installDir]
|
||||
}}</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
as="template"
|
||||
v-for="(dir, dirIdx) in installDirs"
|
||||
:key="dir"
|
||||
:value="dirIdx"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected
|
||||
? 'font-semibold text-zinc-100'
|
||||
: 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ dir }}</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
<div class="text-zinc-400 text-sm mt-2">
|
||||
Add more install directories in
|
||||
<PageWidget to="/settings/downloads">
|
||||
<WrenchIcon class="size-3" />
|
||||
Settings
|
||||
</PageWidget>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="installError" class="mt-1 rounded-md bg-red-600/10 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ installError }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #buttons>
|
||||
<LoadingButton
|
||||
@click="() => install()"
|
||||
:disabled="!(versionOptions && versionOptions.length > 0)"
|
||||
:loading="installLoading"
|
||||
type="submit"
|
||||
class="ml-2 w-full sm:w-fit"
|
||||
>
|
||||
Install
|
||||
</LoadingButton>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||
@click="installFlowOpen = false"
|
||||
ref="cancelButtonRef"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
|
||||
<!--
|
||||
Dear future DecDuck,
|
||||
This v-if is necessary for Vue rendering reasons
|
||||
(it tries to access the game version for not installed games)
|
||||
You have already tried to remove it
|
||||
Don't.
|
||||
-->
|
||||
<GameOptionsModal
|
||||
v-if="status.type === GameStatusEnum.Installed"
|
||||
v-model="configureModalOpen"
|
||||
:game-id="game.id"
|
||||
/>
|
||||
|
||||
<Transition
|
||||
enter="transition ease-out duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="transition ease-in duration-200"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="fullscreenImage"
|
||||
class="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||
@click="fullscreenImage = null"
|
||||
>
|
||||
<div
|
||||
class="relative w-full h-full flex items-center justify-center"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="absolute top-4 right-4 p-2 rounded-full bg-zinc-900/50 text-zinc-100 hover:bg-zinc-900 transition-colors"
|
||||
@click.stop="fullscreenImage = null"
|
||||
>
|
||||
<XMarkIcon class="size-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="mediaUrls.length > 1"
|
||||
@click.stop="previousImage()"
|
||||
class="absolute left-4 p-3 rounded-full bg-zinc-900/50 text-zinc-100 hover:bg-zinc-900 transition-colors"
|
||||
>
|
||||
<ChevronLeftIcon class="size-6" />
|
||||
</button>
|
||||
<button
|
||||
v-if="mediaUrls.length > 1"
|
||||
@click.stop="nextImage()"
|
||||
class="absolute right-4 p-3 rounded-full bg-zinc-900/50 text-zinc-100 hover:bg-zinc-900 transition-colors"
|
||||
>
|
||||
<ChevronRightIcon class="size-6" />
|
||||
</button>
|
||||
|
||||
<TransitionGroup
|
||||
name="slide"
|
||||
tag="div"
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
@click.stop
|
||||
>
|
||||
<img
|
||||
v-for="(url, index) in mediaUrls"
|
||||
v-show="currentImageIndex === index"
|
||||
:key="index"
|
||||
:src="url"
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
:alt="`${game.mName} screenshot ${index + 1}`"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
|
||||
<div
|
||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-full bg-zinc-900/50 backdrop-blur-sm"
|
||||
>
|
||||
<p class="text-zinc-100 text-sm font-medium">
|
||||
{{ currentImageIndex + 1 }} / {{ mediaUrls.length }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronUpDownIcon,
|
||||
WrenchIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
XMarkIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
PhotoIcon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline";
|
||||
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { micromark } from "micromark";
|
||||
import { GameStatusEnum } from "~/types";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const id = route.params.id.toString();
|
||||
|
||||
const { game: rawGame, status } = await useGame(id);
|
||||
const game = ref(rawGame);
|
||||
|
||||
const remoteUrl: string = await invoke("gen_drop_url", {
|
||||
path: `/store/${game.value.id}`,
|
||||
});
|
||||
|
||||
const bannerUrl = await useObject(game.value.mBannerObjectId);
|
||||
|
||||
// Get all available images
|
||||
const mediaUrls = await Promise.all(
|
||||
game.value.mImageCarouselObjectIds.map(async (v) => {
|
||||
const src = await useObject(v);
|
||||
return src;
|
||||
})
|
||||
);
|
||||
|
||||
const htmlDescription = micromark(game.value.mDescription);
|
||||
|
||||
const installFlowOpen = ref(false);
|
||||
const versionOptions = ref<
|
||||
undefined | Array<{ versionName: string; platform: string }>
|
||||
>();
|
||||
const installDirs = ref<undefined | Array<string>>();
|
||||
const currentImageIndex = ref(0);
|
||||
|
||||
const configureModalOpen = ref(false);
|
||||
|
||||
async function installFlow() {
|
||||
installFlowOpen.value = true;
|
||||
versionOptions.value = undefined;
|
||||
installDirs.value = undefined;
|
||||
|
||||
try {
|
||||
versionOptions.value = await invoke("fetch_game_verion_options", {
|
||||
gameId: game.value.id,
|
||||
});
|
||||
console.log(versionOptions.value);
|
||||
installDirs.value = await invoke("fetch_download_dir_stats");
|
||||
} catch (error) {
|
||||
installError.value = (error as string).toString();
|
||||
}
|
||||
}
|
||||
|
||||
const installLoading = ref(false);
|
||||
const installError = ref<string | undefined>();
|
||||
const installVersionIndex = ref(0);
|
||||
const installDir = ref(0);
|
||||
async function install() {
|
||||
try {
|
||||
if (!versionOptions.value) throw new Error("Versions have not been loaded");
|
||||
installLoading.value = true;
|
||||
await invoke("download_game", {
|
||||
gameId: game.value.id,
|
||||
gameVersion: versionOptions.value[installVersionIndex.value].versionName,
|
||||
installDir: installDir.value,
|
||||
});
|
||||
installFlowOpen.value = false;
|
||||
} catch (error) {
|
||||
installError.value = (error as string).toString();
|
||||
}
|
||||
|
||||
installLoading.value = false;
|
||||
}
|
||||
|
||||
async function resumeDownload() {
|
||||
try {
|
||||
await invoke("resume_download", { gameId: game.value.id });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function launch() {
|
||||
try {
|
||||
await invoke("launch_game", { id: game.value.id });
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: `Couldn't run "${game.value.mName}"`,
|
||||
description: `Drop failed to launch "${game.value.mName}": ${e}`,
|
||||
buttonText: "Close",
|
||||
},
|
||||
(e, c) => c()
|
||||
);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function queue() {
|
||||
router.push("/queue");
|
||||
}
|
||||
|
||||
async function uninstall() {
|
||||
await invoke("uninstall_game", { gameId: game.value.id });
|
||||
}
|
||||
|
||||
async function kill() {
|
||||
try {
|
||||
await invoke("kill_game", { gameId: game.value.id });
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: `Couldn't stop "${game.value.mName}"`,
|
||||
description: `Drop failed to stop "${game.value.mName}": ${e}`,
|
||||
buttonText: "Close",
|
||||
},
|
||||
(e, c) => c()
|
||||
);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function nextImage() {
|
||||
currentImageIndex.value = (currentImageIndex.value + 1) % mediaUrls.length;
|
||||
}
|
||||
|
||||
function previousImage() {
|
||||
currentImageIndex.value =
|
||||
(currentImageIndex.value - 1 + mediaUrls.length) % mediaUrls.length;
|
||||
}
|
||||
|
||||
const fullscreenImage = ref<string | null>(null);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(82 82 91) transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(82 82 91);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
19
main/pages/library/index.vue
Normal file
19
main/pages/library/index.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
|
||||
<div class="h-full flex flex-col items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="flex flex-col items-center gap-y-4">
|
||||
<div class="p-4 rounded-xl bg-zinc-700/50 backdrop-blur-sm">
|
||||
<RocketLaunchIcon class="size-12 text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-display font-semibold text-zinc-100">Select a game</h3>
|
||||
<p class="mt-1 text-sm text-zinc-400">Choose a game from your library to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { RocketLaunchIcon } from '@heroicons/vue/24/outline';
|
||||
</script>
|
||||
212
main/pages/queue.vue
Normal file
212
main/pages/queue.vue
Normal file
@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="bg-zinc-950 p-4 min-h-full space-y-4">
|
||||
<div
|
||||
class="h-16 overflow-hidden relative rounded-xl flex flex-row border border-zinc-900"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900 z-10 w-32 flex flex-col gap-x-2 text-blue-400 font-display items-left justify-center pl-2"
|
||||
>
|
||||
<span class="font-semibold">{{ formatKilobytes(stats.speed) }}/s</span>
|
||||
<span v-if="stats.time > 0" class="text-sm"
|
||||
>{{ formatTime(stats.time) }} left</span
|
||||
>
|
||||
</div>
|
||||
<div class="absolute inset-0 h-full flex flex-row items-end justify-end">
|
||||
<div
|
||||
v-for="bar in speedHistory"
|
||||
:style="{ height: `${(bar / speedMax) * 100}%` }"
|
||||
class="w-[8px] bg-blue-600/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<draggable v-model="queue.queue" @end="onEnd">
|
||||
<template #item="{ element }: { element: (typeof queue.value.queue)[0] }">
|
||||
<li
|
||||
v-if="games[element.meta.id]"
|
||||
:key="element.meta.id"
|
||||
class="mb-4 bg-zinc-900 rounded-lg flex flex-row justify-between gap-x-6 py-5 px-4"
|
||||
>
|
||||
<div class="w-full flex items-center max-w-md gap-x-4 relative">
|
||||
<img
|
||||
class="size-24 flex-none bg-zinc-800 object-cover rounded"
|
||||
:src="games[element.meta.id].cover"
|
||||
alt=""
|
||||
/>
|
||||
<div class="min-w-0 flex-auto">
|
||||
<p class="text-xl font-semibold text-zinc-100">
|
||||
<NuxtLink :href="`/library/${element.meta.id}`" class="">
|
||||
<span class="absolute inset-x-0 -top-px bottom-0" />
|
||||
{{ games[element.meta.id].game.mName }}
|
||||
</NuxtLink>
|
||||
</p>
|
||||
<p class="mt-1 flex text-xs/5 text-gray-500">
|
||||
{{ games[element.meta.id].game.mShortDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-x-4">
|
||||
<div class="hidden sm:flex sm:flex-col sm:items-end">
|
||||
<p class="text-md text-zinc-500 uppercase font-display font-bold">
|
||||
{{ element.status }}
|
||||
</p>
|
||||
<div
|
||||
v-if="element.progress"
|
||||
class="mt-1 w-96 bg-zinc-800 rounded-lg overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-2 bg-blue-600"
|
||||
:style="{ width: `${element.progress * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="mt-2 inline-flex items-center gap-x-1 text-zinc-400 text-sm font-display"
|
||||
><span class="text-zinc-300">{{
|
||||
formatKilobytes(element.current / 1000)
|
||||
}}</span>
|
||||
/
|
||||
<span class="">{{ formatKilobytes(element.max / 1000) }}</span
|
||||
><ServerIcon class="size-5"
|
||||
/></span>
|
||||
</div>
|
||||
<button @click="() => cancelGame(element.meta)" class="group">
|
||||
<XMarkIcon
|
||||
class="transition size-8 flex-none text-zinc-600 group-hover:text-zinc-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<p v-else>Loading...</p>
|
||||
</template>
|
||||
</draggable>
|
||||
<div
|
||||
class="text-zinc-600 uppercase font-semibold font-display w-full text-center"
|
||||
v-if="queue.queue.length == 0"
|
||||
>
|
||||
No items in the queue
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ServerIcon, XMarkIcon } from "@heroicons/vue/20/solid";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { GameStatusEnum, type DownloadableMetadata, type Game, type GameStatus } from "~/types";
|
||||
|
||||
// const actionNames = {
|
||||
// [GameStatusEnum.Downloading]: "downloading",
|
||||
// [GameStatusEnum.Verifying]: "verifying",
|
||||
// }
|
||||
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
window.addEventListener("resize", (event) => {
|
||||
windowWidth.value = window.innerWidth;
|
||||
});
|
||||
|
||||
const queue = useQueueState();
|
||||
const stats = useStatsState();
|
||||
const speedHistory = useState<Array<number>>(() => []);
|
||||
const speedHistoryMax = computed(() => windowWidth.value / 8);
|
||||
const speedMax = computed(
|
||||
() => speedHistory.value.reduce((a, b) => (a > b ? a : b)) * 1.3
|
||||
);
|
||||
const previousGameId = ref<string | undefined>();
|
||||
|
||||
const games: Ref<{
|
||||
[key: string]: { game: Game; status: Ref<GameStatus>; cover: string };
|
||||
}> = ref({});
|
||||
|
||||
function resetHistoryGraph() {
|
||||
speedHistory.value = [];
|
||||
stats.value = { time: 0, speed: 0 };
|
||||
}
|
||||
function checkReset(v: QueueState) {
|
||||
const currentGame = v.queue.at(0)?.meta.id;
|
||||
// If we're finished
|
||||
if (!currentGame && previousGameId.value) {
|
||||
previousGameId.value = undefined;
|
||||
resetHistoryGraph();
|
||||
return;
|
||||
}
|
||||
// If we don't have a game
|
||||
if (!currentGame) return;
|
||||
// If we started a new download
|
||||
if (currentGame && !previousGameId.value) {
|
||||
previousGameId.value = currentGame;
|
||||
resetHistoryGraph();
|
||||
return;
|
||||
}
|
||||
// If it's a different game now
|
||||
if (currentGame != previousGameId.value) {
|
||||
previousGameId.value = currentGame;
|
||||
resetHistoryGraph();
|
||||
return;
|
||||
}
|
||||
}
|
||||
watch(queue, (v) => {
|
||||
loadGamesForQueue(v);
|
||||
checkReset(v);
|
||||
});
|
||||
|
||||
watch(stats, (v) => {
|
||||
const newLength = speedHistory.value.push(v.speed);
|
||||
if (newLength > speedHistoryMax.value) {
|
||||
speedHistory.value.splice(0, 1);
|
||||
}
|
||||
checkReset(queue.value);
|
||||
});
|
||||
|
||||
function loadGamesForQueue(v: typeof queue.value) {
|
||||
for (const {
|
||||
meta: { id },
|
||||
} of v.queue) {
|
||||
if (games.value[id]) return;
|
||||
(async () => {
|
||||
const gameData = await useGame(id);
|
||||
const cover = await useObject(gameData.game.mCoverObjectId);
|
||||
games.value[id] = { ...gameData, cover };
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
loadGamesForQueue(queue.value);
|
||||
|
||||
async function onEnd(event: { oldIndex: number; newIndex: number }) {
|
||||
await invoke("move_download_in_queue", {
|
||||
oldIndex: event.oldIndex,
|
||||
newIndex: event.newIndex,
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelGame(meta: DownloadableMetadata) {
|
||||
await invoke("cancel_game", { meta });
|
||||
}
|
||||
|
||||
function formatKilobytes(bytes: number): string {
|
||||
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
const scalar = 1000;
|
||||
|
||||
while (value >= scalar && unitIndex < units.length - 1) {
|
||||
value /= scalar;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ${Math.round(seconds % 60)}s`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
</script>
|
||||
9
main/pages/quit.vue
Normal file
9
main/pages/quit.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<!-- This is perhaps one of the most complicated pages in this app -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
await invoke("quit");
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
128
main/pages/settings.vue
Normal file
128
main/pages/settings.vue
Normal file
@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-7xl px-8">
|
||||
<div class="border-b border-zinc-700 py-5">
|
||||
<h3 class="text-base font-semibold font-display leading-6 text-zinc-100">
|
||||
Settings
|
||||
</h3>
|
||||
</div>
|
||||
<div class="mt-5 flex flex-row gap-12">
|
||||
<nav class="flex flex-col" aria-label="Sidebar">
|
||||
<ul role="list" class="-mx-2 space-y-1">
|
||||
<li v-for="(item, itemIdx) in navigation" :key="item.prefix">
|
||||
<NuxtLink :href="item.route" :class="[
|
||||
itemIdx === currentNavigation
|
||||
? 'bg-zinc-800/50 text-zinc-100'
|
||||
: 'text-zinc-400 hover:bg-zinc-800/30 hover:text-zinc-200',
|
||||
'transition group flex gap-x-3 rounded-md p-2 pr-12 text-sm font-semibold leading-6',
|
||||
]">
|
||||
<component :is="item.icon" :class="[
|
||||
itemIdx === currentNavigation
|
||||
? 'text-zinc-100'
|
||||
: 'text-zinc-400 group-hover:text-zinc-200',
|
||||
'transition h-6 w-6 shrink-0',
|
||||
]" aria-hidden="true" />
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="grow">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
CubeIcon,
|
||||
HomeIcon,
|
||||
RectangleGroupIcon,
|
||||
BugAntIcon,
|
||||
} from "@heroicons/vue/16/solid";
|
||||
import type { Component } from "vue";
|
||||
import type { NavigationItem } from "~/types";
|
||||
import { platform } from '@tauri-apps/plugin-os';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { UserIcon } from "@heroicons/vue/20/solid";
|
||||
|
||||
const systemData = await invoke<{
|
||||
clientId: string;
|
||||
baseUrl: string;
|
||||
dataDir: string;
|
||||
logLevel: string;
|
||||
}>("fetch_system_data");
|
||||
|
||||
const isDebugMode = ref(systemData.logLevel.toLowerCase() === "debug");
|
||||
const debugRevealed = ref(false);
|
||||
|
||||
// Track shift key state and debug reveal
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Shift') {
|
||||
isDebugMode.value = true;
|
||||
debugRevealed.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (e) => {
|
||||
if (e.key === 'Shift') {
|
||||
isDebugMode.value = debugRevealed.value || systemData.logLevel.toLowerCase() === "debug";
|
||||
}
|
||||
});
|
||||
|
||||
// Reset debug reveal when leaving the settings page
|
||||
const router = useRouter();
|
||||
router.beforeEach((to) => {
|
||||
if (!to.path.startsWith('/settings')) {
|
||||
debugRevealed.value = false;
|
||||
isDebugMode.value = systemData.logLevel.toLowerCase() === "debug";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Make navigation reactive by wrapping in computed
|
||||
const navigation = computed(() => [
|
||||
{
|
||||
label: "Home",
|
||||
route: "/settings",
|
||||
prefix: "/settings",
|
||||
icon: HomeIcon,
|
||||
},
|
||||
{
|
||||
label: "Interface",
|
||||
route: "/settings/interface",
|
||||
prefix: "/settings/interface",
|
||||
icon: RectangleGroupIcon,
|
||||
},
|
||||
{
|
||||
label: "Downloads",
|
||||
route: "/settings/downloads",
|
||||
prefix: "/settings/downloads",
|
||||
icon: ArrowDownTrayIcon,
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
route: "/settings/account",
|
||||
prefix: "/settings/account",
|
||||
icon: UserIcon
|
||||
},
|
||||
...(isDebugMode.value ? [{
|
||||
label: "Debug Info",
|
||||
route: "/settings/debug",
|
||||
prefix: "/settings/debug",
|
||||
icon: BugAntIcon,
|
||||
}] : []),
|
||||
]);
|
||||
|
||||
const currentPlatform = platform();
|
||||
|
||||
// Use .value to unwrap the computed ref
|
||||
const {currentNavigation} = useCurrentNavigationIndex(navigation.value);
|
||||
|
||||
// Watch for navigation changes and update currentPageIndex
|
||||
watch(navigation, (newNav) => {
|
||||
currentNavigation.value = useCurrentNavigationIndex(newNav).currentNavigation.value;
|
||||
});
|
||||
</script>
|
||||
64
main/pages/settings/account.vue
Normal file
64
main/pages/settings/account.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="border-b border-zinc-700 py-5">
|
||||
<h3 class="text-base font-semibold font-display leading-6 text-zinc-100">
|
||||
General
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-4">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium leading-6 text-zinc-100">Sign out</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-zinc-400">
|
||||
Sign out of your Drop account on this device
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="signOut"
|
||||
type="button"
|
||||
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-md bg-red-600/10 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ error }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useRouter } from "#imports";
|
||||
import { XCircleIcon } from "@heroicons/vue/16/solid";
|
||||
|
||||
const router = useRouter();
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Listen for auth events
|
||||
onMounted(async () => {
|
||||
await listen("auth/signedout", () => {
|
||||
router.push("/auth/signedout");
|
||||
});
|
||||
});
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
error.value = null;
|
||||
await invoke("sign_out");
|
||||
} catch (e) {
|
||||
error.value = `Failed to sign out: ${e}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
134
main/pages/settings/debug.vue
Normal file
134
main/pages/settings/debug.vue
Normal file
@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="divide-y divide-zinc-700">
|
||||
<div class="py-6">
|
||||
<h2 class="text-base font-semibold font-display leading-7 text-zinc-100">
|
||||
Debug Information
|
||||
</h2>
|
||||
<p class="mt-1 text-sm leading-6 text-zinc-400">
|
||||
Technical information about your Drop client installation, helpful for
|
||||
debugging.
|
||||
</p>
|
||||
|
||||
<div class="mt-10 space-y-8">
|
||||
<div>
|
||||
<div class="flex items-center gap-x-3">
|
||||
<FingerPrintIcon class="h-5 w-5 text-zinc-400" />
|
||||
<h3 class="text-sm font-medium leading-6 text-zinc-100">
|
||||
Client ID
|
||||
</h3>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-zinc-400 font-mono ml-8">
|
||||
{{ clientId || "Not signed in" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-x-3">
|
||||
<ComputerDesktopIcon class="h-5 w-5 text-zinc-400" />
|
||||
<h3 class="text-sm font-medium leading-6 text-zinc-100">
|
||||
Platform
|
||||
</h3>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-zinc-400 font-mono ml-8">
|
||||
{{ platformInfo }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-x-3">
|
||||
<ServerIcon class="h-5 w-5 text-zinc-400" />
|
||||
<h3 class="text-sm font-medium leading-6 text-zinc-100">
|
||||
Server URL
|
||||
</h3>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-zinc-400 font-mono ml-8">
|
||||
{{ baseUrl || "Not connected" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-x-3">
|
||||
<FolderIcon class="h-5 w-5 text-zinc-400" />
|
||||
<h3 class="text-sm font-medium leading-6 text-zinc-100">
|
||||
Data Directory
|
||||
</h3>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-zinc-400 font-mono ml-8">
|
||||
{{ dataDir || "Unknown" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-6 flex gap-x-4">
|
||||
<button
|
||||
@click="() => openDataDir()"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<FolderIcon class="h-5 w-5" aria-hidden="true" />
|
||||
Open Data Directory
|
||||
</button>
|
||||
<button
|
||||
@click="() => openLogFile()"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<DocumentTextIcon class="h-5 w-5" aria-hidden="true" />
|
||||
Open Log File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { platform, type } from "@tauri-apps/plugin-os";
|
||||
import {
|
||||
FingerPrintIcon,
|
||||
TagIcon,
|
||||
ComputerDesktopIcon,
|
||||
ServerIcon,
|
||||
FolderIcon,
|
||||
CubeIcon,
|
||||
DocumentTextIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
|
||||
const clientId = ref<string | null>(null);
|
||||
const platformInfo = ref("Loading...");
|
||||
const baseUrl = ref<string | null>(null);
|
||||
const dataDir = ref<string | null>(null);
|
||||
|
||||
const systemData = await invoke<{
|
||||
clientId: string;
|
||||
baseUrl: string;
|
||||
dataDir: string;
|
||||
}>("fetch_system_data");
|
||||
|
||||
clientId.value = systemData.clientId;
|
||||
baseUrl.value = systemData.baseUrl;
|
||||
dataDir.value = systemData.dataDir;
|
||||
|
||||
const currentPlatform = await platform();
|
||||
platformInfo.value = currentPlatform;
|
||||
|
||||
async function openDataDir() {
|
||||
if (!dataDir.value) return;
|
||||
try {
|
||||
await open(dataDir.value);
|
||||
} catch (error) {
|
||||
console.error("Failed to open data dir:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function openLogFile() {
|
||||
if (!dataDir.value) return;
|
||||
try {
|
||||
const logPath = `${dataDir.value}/drop.log`;
|
||||
await open(logPath);
|
||||
} catch (error) {
|
||||
console.error("Failed to open log file:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
300
main/pages/settings/downloads.vue
Normal file
300
main/pages/settings/downloads.vue
Normal file
@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="border-b border-zinc-700 py-5">
|
||||
<h3 class="text-base font-semibold font-display leading-6 text-zinc-100">
|
||||
Downloads
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-5">
|
||||
<div class="border-b border-zinc-600">
|
||||
<div class="-ml-4 -mt-2 flex flex-wrap items-center justify-between sm:flex-nowrap">
|
||||
<div class="ml-4 mt-2 pb-4">
|
||||
<h3 class="text-base font-display font-semibold text-zinc-100">
|
||||
Install directories
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-400 max-w-xl">
|
||||
This is where Drop will download game files to, and store them
|
||||
indefinitely while you play. Drop and games may store other
|
||||
information elsewhere, like saves or mods.
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4 mt-2 shrink-0">
|
||||
<button @click="() => (open = true)" type="button"
|
||||
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
|
||||
Add new directory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul role="list" class="divide-y divide-gray-800">
|
||||
<li v-for="(dir, dirIdx) in dirs" :key="dir" class="flex justify-between gap-x-6 py-5">
|
||||
<div class="flex min-w-0 gap-x-4">
|
||||
<FolderIcon class="h-6 w-6 text-blue-600 flex-none rounded-full" alt="" />
|
||||
<div class="min-w-0 flex-auto">
|
||||
<p class="text-sm/6 text-zinc-100">
|
||||
{{ dir }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-x-6">
|
||||
<button @click="() => deleteDirectory(dirIdx)" :disabled="dirs.length <= 1" :class="[
|
||||
dirs.length <= 1
|
||||
? 'text-zinc-700'
|
||||
: 'text-zinc-400 hover:text-zinc-100',
|
||||
'-m-2.5 block p-2.5',
|
||||
]">
|
||||
<span class="sr-only">Open options</span>
|
||||
<TrashIcon class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="border-t border-zinc-600 py-6">
|
||||
<h3 class="text-base font-display font-semibold text-zinc-100">
|
||||
Download Settings
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-400 max-w-xl">
|
||||
Configure how Drop downloads games and other content.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 max-w-xl">
|
||||
<label for="threads" class="block text-sm font-medium text-zinc-100">
|
||||
Maximum Download Threads
|
||||
</label>
|
||||
<div class="mt-2">
|
||||
<input type="number" name="threads" id="threads" min="1" max="32" v-model="downloadThreads"
|
||||
@keypress="validateNumberInput" @paste="validatePaste"
|
||||
class="block w-full rounded-md border-0 py-1.5 text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 bg-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-zinc-400">
|
||||
The maximum number of concurrent download threads. Higher values may
|
||||
download faster but use more system resources. Default is 4.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-10 space-y-8">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium leading-6 text-zinc-100">Force Offline</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-zinc-400">
|
||||
Drop will not make any external connections
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="forceOffline" :class="[
|
||||
forceOffline ? 'bg-blue-600' : 'bg-zinc-700',
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out'
|
||||
]">
|
||||
<span :class="[
|
||||
forceOffline ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out'
|
||||
]" />
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-6">
|
||||
<button type="button" @click="saveSettings" :disabled="saveState.loading" :class="[
|
||||
'inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 transition-colors duration-300',
|
||||
saveState.success
|
||||
? 'bg-green-600 hover:bg-green-500 focus-visible:outline-green-600'
|
||||
: 'bg-blue-600 hover:bg-blue-500 focus-visible:outline-blue-600',
|
||||
'disabled:bg-blue-600/50 disabled:cursor-not-allowed'
|
||||
]">
|
||||
{{ saveState.success ? 'Saved' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TransitionRoot as="template" :show="open">
|
||||
<Dialog class="relative z-50" @close="open = false">
|
||||
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0" enter-to="opacity-100"
|
||||
leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0">
|
||||
<div class="fixed inset-0 bg-zinc-950 bg-opacity-75 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<TransitionChild as="template" enter="ease-out duration-300"
|
||||
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enter-to="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200"
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<DialogPanel
|
||||
class="relative transform overflow-hidden rounded-lg bg-zinc-900 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mt-3 w-full sm:ml-4 sm:mt-0">
|
||||
<div>
|
||||
<label for="dir" class="block text-sm/6 font-medium text-zinc-100">Select game directory</label>
|
||||
<div class="mt-2">
|
||||
<button @click="() => selectDirectory()"
|
||||
class="block text-left w-full rounded-md border-0 px-3 py-1.5 text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 bg-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm/6">
|
||||
{{
|
||||
currentDirectory ?? "Click to select a directory..."
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-zinc-400" id="dir-description">
|
||||
Select an empty directory to add.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<LoadingButton :disabled="currentDirectory == undefined" type="button" :loading="createDirectoryLoading"
|
||||
@click="() => submitDirectory()" :class="[
|
||||
'inline-flex w-full shadow-sm sm:ml-3 sm:w-auto',
|
||||
currentDirectory === undefined
|
||||
? 'text-zinc-400 bg-blue-600/10 hover:bg-blue-600/10'
|
||||
: 'text-white bg-blue-600 hover:bg-blue-500',
|
||||
]">
|
||||
Add
|
||||
</LoadingButton>
|
||||
<button type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||
@click="() => cancelDirectory()" ref="cancelButtonRef">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="error" class="mt-3 rounded-md bg-red-600/10 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ error }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from "@headlessui/vue";
|
||||
import { FolderIcon, TrashIcon, XCircleIcon } from "@heroicons/vue/16/solid";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Switch } from '@headlessui/vue'
|
||||
import { type Settings } from "~/types";
|
||||
|
||||
const open = ref(false);
|
||||
const currentDirectory = ref<string | undefined>(undefined);
|
||||
const error = ref<string | undefined>(undefined);
|
||||
const createDirectoryLoading = ref(false);
|
||||
|
||||
const dirs = ref<Array<string>>([]);
|
||||
|
||||
const settings = await invoke<Settings>("fetch_settings");
|
||||
const downloadThreads = ref(settings?.maxDownloadThreads ?? 4);
|
||||
const forceOffline = ref(settings?.forceOffline ?? false);
|
||||
|
||||
const saveState = reactive({
|
||||
loading: false,
|
||||
success: false
|
||||
});
|
||||
|
||||
async function updateDirs() {
|
||||
const newDirs = await invoke<Array<string>>("fetch_download_dir_stats");
|
||||
dirs.value = newDirs;
|
||||
}
|
||||
|
||||
await updateDirs();
|
||||
|
||||
async function selectDirectoryDialog(): Promise<string> {
|
||||
const res = await invoke("plugin:dialog|open", {
|
||||
options: { directory: true },
|
||||
});
|
||||
|
||||
return res as string;
|
||||
}
|
||||
|
||||
async function selectDirectory() {
|
||||
try {
|
||||
const dir = await selectDirectoryDialog();
|
||||
currentDirectory.value = dir;
|
||||
} catch (e) {
|
||||
error.value = e as string;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDirectory() {
|
||||
open.value = false;
|
||||
currentDirectory.value = undefined;
|
||||
}
|
||||
|
||||
async function submitDirectory() {
|
||||
try {
|
||||
error.value = undefined;
|
||||
if (!currentDirectory.value)
|
||||
throw new Error("Please select a directory first");
|
||||
createDirectoryLoading.value = true;
|
||||
|
||||
// Add directory
|
||||
await invoke("add_download_dir", { newDir: currentDirectory.value });
|
||||
|
||||
// Update list
|
||||
await updateDirs();
|
||||
|
||||
currentDirectory.value = undefined;
|
||||
createDirectoryLoading.value = false;
|
||||
open.value = false;
|
||||
} catch (e) {
|
||||
error.value = e as string;
|
||||
createDirectoryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDirectory(index: number) {
|
||||
await invoke("delete_download_dir", { index });
|
||||
await updateDirs();
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
saveState.loading = true;
|
||||
await invoke("update_settings", {
|
||||
newSettings: { maxDownloadThreads: downloadThreads.value, forceOffline: forceOffline.value },
|
||||
});
|
||||
|
||||
// Show success state
|
||||
saveState.success = true;
|
||||
|
||||
// Reset back to normal state after 2 seconds
|
||||
setTimeout(() => {
|
||||
saveState.success = false;
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
} finally {
|
||||
saveState.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateNumberInput(event: KeyboardEvent) {
|
||||
// Allow only numbers and basic control keys
|
||||
if (!/^\d$/.test(event.key) &&
|
||||
!['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function validatePaste(event: ClipboardEvent) {
|
||||
// Prevent paste if content contains non-numeric characters
|
||||
const pastedData = event.clipboardData?.getData('text');
|
||||
if (pastedData && !/^\d+$/.test(pastedData)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
59
main/pages/settings/index.vue
Normal file
59
main/pages/settings/index.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="border-b border-zinc-700 py-5">
|
||||
<h3 class="text-base font-semibold font-display leading-6 text-zinc-100">
|
||||
General
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-8">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium leading-6 text-zinc-100">
|
||||
Start with system
|
||||
</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-zinc-400">
|
||||
Drop will automatically start when you log into your computer
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="autostartEnabled"
|
||||
:class="[
|
||||
autostartEnabled ? 'bg-blue-600' : 'bg-zinc-700',
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
autostartEnabled ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
]"
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Switch } from "@headlessui/vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
defineProps<{}>();
|
||||
|
||||
const autostartEnabled = ref<boolean>(false);
|
||||
|
||||
// Load initial state
|
||||
invoke("get_autostart_enabled").then((enabled) => {
|
||||
autostartEnabled.value = enabled as boolean;
|
||||
});
|
||||
|
||||
// Watch for changes and update autostart
|
||||
watch(autostartEnabled, async (newValue: boolean) => {
|
||||
try {
|
||||
await invoke("toggle_autostart", { enabled: newValue });
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle autostart:", error);
|
||||
// Revert the toggle if it failed
|
||||
autostartEnabled.value = !newValue;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
7
main/pages/settings/interface.vue
Normal file
7
main/pages/settings/interface.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
32
main/pages/setup/index.vue
Normal file
32
main/pages/setup/index.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div
|
||||
class="mx-auto flex flex-col items-center gap-y-4 max-w-2xl py-32 sm:py-48 lg:py-56"
|
||||
>
|
||||
<div>
|
||||
<Wordmark />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h1
|
||||
class="text-balance text-4xl font-bold font-display tracking-tight text-zinc-100 sm:text-6xl"
|
||||
>
|
||||
Let's get you set up
|
||||
</h1>
|
||||
<p class="mt-6 text-lg leading-8 text-zinc-400">
|
||||
You'll need to have set up a Drop instance and created an account on it. If you're connecting to another person's instance, you'll need the url and an account.
|
||||
</p>
|
||||
<div class="mt-10 flex items-center justify-center gap-x-6">
|
||||
<NuxtLink
|
||||
href="/setup/server"
|
||||
class="rounded-md bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>Get started -></NuxtLink
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: "mini",
|
||||
});
|
||||
</script>
|
||||
120
main/pages/setup/server.vue
Normal file
120
main/pages/setup/server.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8"
|
||||
>
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-sm flex flex-col items-center">
|
||||
<Wordmark />
|
||||
<h2
|
||||
class="mt-10 text-center text-2xl font-bold font-display leading-9 tracking-tight text-zinc-100"
|
||||
>
|
||||
Connect to your Drop instance
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<form class="space-y-6" @submit.prevent="connect_wrapper">
|
||||
<div>
|
||||
<label
|
||||
for="company-website"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>Drop instance address</label
|
||||
>
|
||||
<div class="mt-2">
|
||||
<div
|
||||
class="flex rounded-md shadow-sm ring-1 ring-inset ring-zinc-700 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600 sm:max-w-md"
|
||||
>
|
||||
<span
|
||||
v-if="showHttps"
|
||||
class="flex select-none items-center pl-3 text-zinc-500 -mr-2.5 sm:text-sm"
|
||||
>https://</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="company-website"
|
||||
id="company-website"
|
||||
v-model="url"
|
||||
class="block flex-1 border-0 bg-transparent py-1.5 text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder="www.example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LoadingButton :loading="loading" class="w-full">
|
||||
Continue ->
|
||||
</LoadingButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ error }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="mt-10 text-center text-sm text-zinc-500">
|
||||
Don't have one?
|
||||
{{ " " }}
|
||||
<a
|
||||
href="https://github.com/Drop-OSS"
|
||||
target="_blank"
|
||||
class="font-semibold leading-6 text-blue-600 hover:text-blue-500"
|
||||
>Host your own instance -></a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XCircleIcon } from "@heroicons/vue/16/solid";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
definePageMeta({
|
||||
layout: "mini",
|
||||
});
|
||||
|
||||
const url = ref("");
|
||||
const error = ref(undefined);
|
||||
const loading = ref(false);
|
||||
|
||||
const router = useRouter();
|
||||
const showHttps = computed(() => {
|
||||
const prefixes = ["http://", "https://"];
|
||||
|
||||
const doesntHavePrefix = prefixes.findIndex((e) => url.value.startsWith(e.slice(0, url.value.length))) == -1;
|
||||
|
||||
return doesntHavePrefix;
|
||||
})
|
||||
|
||||
async function connect() {
|
||||
const newUrl = url.value.startsWith("http")
|
||||
? url.value
|
||||
: `https://${url.value}`;
|
||||
|
||||
const result = await invoke("use_remote", { url: newUrl });
|
||||
router.push("/auth");
|
||||
}
|
||||
|
||||
function connect_wrapper() {
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
connect()
|
||||
.then(() => {})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
error.value = e;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
37
main/pages/store/index.vue
Normal file
37
main/pages/store/index.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="grow w-full h-full flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<BuildingStorefrontIcon
|
||||
class="h-12 w-12 text-blue-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
|
||||
Store not supported in client
|
||||
</h1>
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-zinc-400 max-w-lg">
|
||||
Currently, Drop requires you to view the store in your browser.
|
||||
Please click the button below to open it in your default browser.
|
||||
</p>
|
||||
<NuxtLink
|
||||
:href="storeUrl"
|
||||
target="_blank"
|
||||
class="mt-6 transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
|
||||
>
|
||||
Open Store <ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
BuildingStorefrontIcon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const storeUrl = await invoke<string>("gen_drop_url", { path: "/store" });
|
||||
</script>
|
||||
Reference in New Issue
Block a user