4 Commits

Author SHA1 Message Date
4fb0a185f6 feat: preliminary peer api 2025-05-13 12:28:18 +10:00
70db79b50f feat: set data dir for headscale 2025-05-12 09:44:08 +10:00
ac7ef6303b feat: new unified data folder 2025-05-12 09:18:42 +10:00
efbc86e73e feat: add new HeadscaleManager 2025-05-12 09:04:28 +10:00
495 changed files with 14566 additions and 31063 deletions

View File

@ -1,9 +1,3 @@
Dockerfile
.github
.vscode
*.md
#### gitignore below
# Nuxt dev/build outputs
.output
.data
@ -14,7 +8,6 @@ dist
# Node dependencies
node_modules
.yarn
# Logs
logs
@ -31,13 +24,3 @@ logs
!.env.example
.data
# deploy template
deploy-template/*
!deploy-template/compose.yml
# generated prisma client
/prisma/client
/prisma/validate

View File

@ -1,5 +1,8 @@
DATABASE_URL="postgres://drop:drop@127.0.0.1:5432/drop"
CLIENT_CERTIFICATES="./.data/ca"
FS_BACKEND_PATH="./.data/objects"
GIANT_BOMB_API_KEY=""
EXTERNAL_URL="http://localhost:3000"

View File

@ -1,15 +1,6 @@
name: CI
on:
push:
branches:
- develop
pull_request:
branches:
- develop
permissions:
contents: read
on: [pull_request, push]
jobs:
typecheck:
@ -21,20 +12,17 @@ jobs:
with:
submodules: true
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "pnpm"
cache: "yarn"
- name: Install dependencies
run: pnpm install
run: yarn install --immutable --network-timeout 1000000
- name: Typecheck
run: pnpm run typecheck
run: yarn typecheck
lint:
name: Lint
@ -45,17 +33,14 @@ jobs:
with:
submodules: true
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "pnpm"
cache: "yarn"
- name: Install dependencies
run: pnpm install
run: yarn install --immutable --network-timeout 1000000
- name: Lint
run: pnpm run lint
run: yarn lint

View File

@ -20,29 +20,8 @@ jobs:
uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 3 # fix for when this gets triggered by tag
fetch-tags: true
ref: ${{ github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine final version
id: get_final_ver
run: |
BASE_VER=v$(jq -r '.version' package.json)
TODAY=$(date +'%Y.%m.%d')
echo "Today will be: $TODAY"
echo "today=$TODAY" >> $GITHUB_OUTPUT
if [[ "${{ github.event_name }}" == "release" ]]; then
FINAL_VER="$BASE_VER"
else
FINAL_VER="${BASE_VER}-nightly.$TODAY"
fi
echo "Drop's release tag will be: $FINAL_VER"
echo "final_ver=$FINAL_VER" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@ -67,7 +46,6 @@ jobs:
ghcr.io/drop-OSS/drop
tags: |
type=schedule,pattern=nightly
type=schedule,pattern=nightly.${{ steps.get_final_ver.outputs.today }}
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
@ -77,33 +55,14 @@ jobs:
# set latest tag for stable releases
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
- name: Cache
uses: actions/cache@v4
id: cache
with:
path: cache-mount
key: cache-mount-${{ hashFiles('Dockerfile') }}
- name: Restore Docker cache mounts
uses: reproducible-containers/buildkit-cache-dance@v3
with:
builder: ${{ steps.setup-buildx.outputs.name }}
cache-dir: cache-mount
dockerfile: Dockerfile
skip-extraction: ${{ steps.cache.outputs.cache-hit }}
- name: Build and push image
id: build-and-push
uses: docker/build-push-action@v6
with:
context: .
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILD_DROP_VERSION=${{ steps.get_final_ver.outputs.final_ver }}

3
.gitignore vendored
View File

@ -33,5 +33,4 @@ deploy-template/*
!deploy-template/compose.yml
# generated prisma client
/prisma/client
/prisma/validate
/prisma/client

2
.gitmodules vendored
View File

@ -1,3 +1,3 @@
[submodule "drop-base"]
path = drop-base
url = https://github.com/Drop-OSS/drop-base.git
url = https://github.com/Drop-OSS/drop-base.git

View File

@ -1,3 +1 @@
drop-base/
# file is fully managed by pnpm, no reason to break it
pnpm-lock.yaml

View File

@ -1,12 +0,0 @@
{
"recommendations": [
"lokalise.i18n-ally",
"esbenp.prettier-vscode",
"Prisma.prisma",
"bradlc.vscode-tailwindcss",
"Vue.volar",
"arktypeio.arkdark",
"EditorConfig.EditorConfig",
"dbaeumer.vscode-eslint"
]
}

18
.vscode/settings.json vendored
View File

@ -17,21 +17,5 @@
"strings": "on"
},
// prioritize ArkType's "type" for autoimports
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"],
// i18n Ally settings
"i18n-ally.sortKeys": true,
"i18n-ally.keepFulfilled": true,
"i18n-ally.extract.autoDetect": true,
"i18n-ally.localesPaths": ["i18n", "i18n/locales"],
"i18n-ally.keystyle": "nested",
"i18n-ally.extract.ignored": [
"string >= 14",
"string.alphanumeric >= 5",
"/api/v1/admin/import/version/preload?id=${encodeURIComponent(\n gameId,\n )}&version=${encodeURIComponent(version)}"
],
"i18n-ally.extract.ignoredByFiles": {
"pages/admin/library/sources/index.vue": ["Filesystem"],
"components/NewsArticleCreateButton.vue": ["[", "`", "Enter"],
"server/api/v1/auth/signin/simple.post.ts": ["boolean | undefined"]
}
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"]
}

View File

@ -1,3 +1,242 @@
# Contributing
# CONTRIBUTING GUIDELINES
Check out our contributing guidelines on our developer docs: [https://developer.droposs.org/contributing](https://developer.droposs.org/contributing).
Drop is a community-driven project. Contribution is welcome, encouraged, and appreciated.
It is also essential for the development of the project.
First, please take a moment to review our [code of conduct](CODE_OF_CONDUCT.md).
These guidelines are an attempt at better addressing pending
issues and pull requests. Please read them closely.
Foremost, be so kind as to [search](#use-the-search-luke). This ensures any contribution
you would make is not already covered.
<!-- TOC updateonsave:true depthfrom:2 -->
- [Reporting Issues](#reporting-issues)
- [You have a problem](#you-have-a-problem)
- [You have a suggestion](#you-have-a-suggestion)
- [Submitting Pull Requests](#submitting-pull-requests)
- [Getting started](#getting-started)
- [You have a solution](#you-have-a-solution)
- [You have an addition](#you-have-an-addition)
- [Use the Search, Luke](#use-the-search-luke)
- [Commit Guidelines](#commit-guidelines)
- [Format](#format)
- [Style](#style)
<!-- /TOC -->
## Reporting Issues
### You have a problem
Please be so kind as to [search](#use-the-search-luke) for any open issue already covering
your problem.
If you find one, comment on it, so we know more people are experiencing it.
<!--
TODO: Add Troubleshooting
If not, look at the [Troubleshooting](https://github.com/Drop-OSS/docs/Troubleshooting)
page for instructions on how to gather data to better debug your problem.
-->
If you cannot find an existing issue, you can go ahead and create an issue with as much
detail as you can provide.
It should include the data gathered as indicated above, along with the following:
1. How to reproduce the problem
2. What the correct behavior should be
3. What the actual behavior is
Please copy to anyone relevant (e.g. plugin maintainers) by mentioning their GitHub handle
(starting with `@`) in your message.
We will do our very best to help you.
### You have a suggestion
Please be so kind as to [search](#use-the-search-luke) for any open issue already covering
your suggestion.
If you find one, comment on it, so we know more people are supporting it.
If not, you can go ahead and create an issue. Please copy to anyone relevant (e.g. plugin
maintainers) by mentioning their GitHub handle (starting with `@`) in your message.
## Submitting Pull Requests
### Getting started
You should be familiar with the basics of
[contributing on GitHub](https://help.github.com/articles/using-pull-requests)
<!--and have a fork
[properly set up](https://github.com/drop/docs/Contribution-Technical-Practices).
-->
You MUST always create PRs with _a dedicated branch_ based on the latest upstream tree.
If you create your own PR, please make sure you do it right. Also be so kind as to reference
any issue that would be solved in the PR description body,
[for instance](https://help.github.com/articles/closing-issues-via-commit-messages/)
_"Fixes #XXXX"_ for issue number XXXX.
### You have a solution
Please be so kind as to [search](#use-the-search-luke) for any open issue already covering
your [problem](#you-have-a-problem), and any pending/merged/rejected PR covering your solution.
If the solution is already reported, try it out and +1 the pull request if the
solution works ok. On the other hand, if you think your solution is better, post
it with reference to the other one so we can have both solutions to compare.
If not, then go ahead and submit a PR. Please copy to anyone relevant (e.g. plugin
maintainers) by mentioning their GitHub handle (starting with `@`) in your message.
### You have an addition
We are absolutely accepting more contributions or features to drop, but please, make sure
that it is reasonable. Contributions that only cover a very small niche are likely to not
be added.
Please be so kind as to [search](#use-the-search-luke) for any pending, merged or rejected Pull Requests
covering or related to what you want to add.
If you find one, try it out and work with the author on a common solution.
If not, then go ahead and submit a PR. Please copy to anyone relevant (e.g. plugin
maintainers) by mentioning their GitHub handle (starting with `@`) in your message.
For any extensive change, such as API changes, you will have to find testers to +1 your PR.
---
## Use the Search, Luke
_May the Force (of past experiences) be with you_
GitHub offers [many search features](https://help.github.com/articles/searching-github/)
to help you check whether a similar contribution to yours already exists. Please search
before making any contribution, it avoids duplicates and eases maintenance. Trust me,
that works 90% of the time.
You can also take a look at the [FAQ](https://github.com/Drop-OSS/docs/wiki/FAQ)
to be sure your contribution has not already come up.
If all fails, your thing has probably not been reported yet, so you can go ahead
and [create an issue](#reporting-issues) or [submit a PR](#submitting-pull-requests).
---
## Commit Guidelines
Drop uses the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
specification. The automatic changelog tool uses these to automatically generate
a changelog based on the commit messages. Here's a guide to writing a commit message
to allow this:
### Format
```
type(scope)!: subject
```
- `type`: the type of the commit is one of the following:
- `feat`: new features.
- `fix`: bug fixes.
- `docs`: documentation changes.
- `refactor`: refactor of a particular code section without introducing
new features or bug fixes.
- `style`: code style improvements.
- `perf`: performance improvements.
- `test`: changes to the test suite.
- `ci`: changes to the CI system.
- `build`: changes to the build system.
- `chore`: for other changes that don't match previous types. This doesn't appear
in the changelog.
- `scope`: section of the codebase that the commit makes changes to. If it makes changes to
many sections, or if no section in particular is modified, leave blank without the parentheses.
Examples:
- Commit that changes the `git` plugin:
```
feat(git): add alias for `git commit`
```
- Commit that changes many plugins:
```
style: fix inline declaration of arrays
```
For changes to plugins or themes, the scope should be the plugin or theme name:
- ✅ `fix(agnoster): commit subject`
- ❌ `fix(theme/agnoster): commit subject`
- `!`: this goes after the `scope` (or the `type` if scope is empty), to indicate that the commit
introduces breaking changes.
Optionally, you can specify a message that the changelog tool will display to the user to indicate
what's changed and what they can do to deal with it. You can use multiple lines to type this message;
the changelog parser will keep reading until the end of the commit message or until it finds an empty
line.
Example (made up):
```
style(agnoster)!: change dirty git repo glyph
BREAKING CHANGE: the glyph to indicate when a git repository is dirty has
changed from a Powerline character to a standard UTF-8 emoji. You can
change it back by setting `ZSH_THEME_DIRTY_GLYPH`.
Fixes #420
Co-authored-by: Username <email>
```
- `subject`: a brief description of the changes. This will be displayed in the changelog. If you need
to specify other details, you can use the commit body, but it won't be visible.
Formatting tricks: the commit subject may contain:
- Links to related issues or PRs by writing `#issue`. This will be highlighted by the changelog tool:
```
feat(archlinux): add support for aura AUR helper (#9467)
```
- Formatted inline code by using backticks: the text between backticks will also be highlighted by
the changelog tool:
```
feat(shell-proxy): enable unexported `DEFAULT_PROXY` setting (#9774)
```
### Style
Try to keep the first commit line short. It's harder to do using this commit style but try to be
concise, and if you need more space, you can use the commit body. Try to make sure that the commit
subject is clear and precise enough that users will know what changed by just looking at the changelog.
---
<!--
## Volunteer
Very nice!! :)
Please have a look at the [Volunteer](https://github.com/ohmyzsh/ohmyzsh/wiki/Volunteers)
page for instructions on where to start and more.
-->
## Reference
This contributing guide is adapted from the
[oh-my-zsh contribution guide](https://github.com/ohmyzsh/ohmyzsh/blob/master/CONTRIBUTING.md).
If there are any issues with this, please email admin@deepcore.dev.

View File

@ -1,61 +1,30 @@
# syntax=docker/dockerfile:1
# pull pre-configured and updated build environment
FROM debian:testing-20250317-slim AS build-system
FROM node:lts-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# setup workdir - has to be the same filepath as app because fuckin' Prisma
WORKDIR /app
# so corepack knows pnpm's version
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# prevent prompt to download
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# setup for offline
RUN corepack pack
# don't call out to network anymore
ENV COREPACK_ENABLE_NETWORK=0
### Unified deps builder
FROM base AS deps
RUN pnpm install --frozen-lockfile --ignore-scripts
### Build for app
FROM base AS build-system
ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1
# add git so drop can determine its git ref at build
RUN apk add --no-cache git
# copy deps and rest of project files
COPY --from=deps /app/node_modules ./node_modules
# install dependencies and build
RUN apt-get update -y
RUN apt-get install node-corepack -y
RUN corepack enable
COPY . .
RUN NUXT_TELEMETRY_DISABLED=1 yarn install --network-timeout 1000000
RUN NUXT_TELEMETRY_DISABLED=1 yarn prisma generate
RUN NUXT_TELEMETRY_DISABLED=1 yarn build
ARG BUILD_DROP_VERSION
ARG BUILD_GIT_REF
# create run environment for Drop
FROM node:lts-slim AS run-system
# build
RUN pnpm run postinstall && pnpm run build
WORKDIR /app
### create run environment for Drop
FROM base AS run-system
ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1
RUN apk add --no-cache pnpm
RUN pnpm install prisma@6.11.1
# init prisma to download all required files
RUN pnpm prisma init
COPY --from=build-system /app/package.json ./
COPY --from=build-system /app/.output ./app
COPY --from=build-system /app/prisma ./prisma
COPY --from=build-system /app/package.json ./
COPY --from=build-system /app/build ./startup
ENV LIBRARY="/library"
ENV DATA="/data"
# OpenSSL as a dependency for Drop (TODO: seperate build environment)
RUN apt-get update -y && apt-get install -y openssl
RUN yarn global add prisma@6.7.0
CMD ["sh", "/app/startup/launch.sh"]
CMD ["/app/startup/launch.sh"]

View File

@ -6,32 +6,72 @@
# Drop
[![Website](https://img.shields.io/badge/website-000000?style=for-the-badge&logo=About.me&logoColor=white)](https://droposs.org)
[![Docs](https://img.shields.io/badge/DOCS-black?style=for-the-badge&logo=docusaurus)](https://docs.droposs.org/)
[![Static Badge](https://img.shields.io/badge/FORUM-blue?style=for-the-badge)](https://forum.droposs.org)
[![GitHub License](https://img.shields.io/badge/AGPL--3.0-red?style=for-the-badge)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ACq4qZp4a9)
[![Open Collective](https://img.shields.io/badge/OpenCollective-1F87FF?style=for-the-badge&logo=OpenCollective&logoColor=white)](https://opencollective.com/drop-oss)
[![Weblate project translated](https://img.shields.io/weblate/progress/drop?server=https%3A%2F%2Ftranslate.droposs.org&style=for-the-badge&logo=weblate)
](https://translate.droposs.org/engage/drop/)
Drop is an open-source game distribution platform, similar to GameVault or Steam. It's designed to distribute and share DRM-free games quickly, all while being incredibly flexible, beautiful, and fast.
<div align="center">
<img src="https://droposs.org/_ipx/f_webp&q_80/images/carousel/store.png" alt="Drop Screenshot" width="900rem"/>
</div>
Drop is an open-source game distribution platform, like GameVault or Steam. It's designed to distribute and shared DRM-free game quickly, all while being incredibly flexible, beautiful and fast.
## Philosophy
1. Drop is flexible. While abstractions and interfaces can complicate the codebase, the flexibility is worth it.
2. Drop is secure. The nature of Drop means an instance can never be accessible without authentication. In line with #1, Drop also supports a huge variety of authentication mechanisms, from username/password to SSO.
3. Drop is user-friendly. The interface is designed to be clean and simple to use, with advanced features available to users who want them.
1. Drop is flexible. While abstractions and interfaces can make the codebase more complicated, the flexibility is worth it.
2. Drop is secure. The nature of Drop means an instance can never be accessible without authentication. In line with #1, Drop also supports a huge variety of authentication mechanisms, from a username/password to SSO.
3. Drop is user-friendly. The interface is designed to be clean and simple to use, with complexity available to the users who want it.
## Deployment
See our documentation on how to [deploy Drop](https://docs.droposs.org/docs/guides/quickstart) for more information.
To just deploy Drop, we've set up a simple docker compose file in deploy-template.
1. Generate a [GiantBomb API Key](https://www.giantbomb.com/api/)
2. Navigate to the deploy-template directory in your terminal (`cd deploy-template`)
3. Edit the compose.yml file (`nano compose.yml`) and copy your GiamtBomb API Key into the GIANT_BOMB_API_KEY environment variable
4. Run `docker compose up -d`
Your drop server should now be running. To register the admin user, navigate to http://your.drop.server.ip:3000/register?id=admin
and fill in the required forms
### Adding a game
To add a game to the drop library, do as follows:
1. Ensure that the current user owns the library folder with `sudo chown -R $(id -u $(whoami)) library`
2. `cd library`
3. `mkdir <GAME_NAME>` with the name of the game which you would like to register
4. `cd <GAME_NAME>`
5. `mkdir <VERSION_NAME>` Upload files for the specific game version to this folder
6. Navigate to http://your.drop.server.ip:3000/
7. Import game metadata (uses GiantBomb API Key) by selecting the game and specifying which entry to import
8. Navigate to http://your.drop.server.ip:3000/admin/library
9. You should see the game which you have just imported listed in this menu. There should be a notification that "Drop has detected you have new verions of this game to import". Select import here.
10. Select the game version to import and thus fill in fields as required.
## Tech Stack
This repo uses the Nuxt 3 + TailwindCSS stack, with the `yarn` package manager.
For the database, Drop uses Prisma connected to PostgreSQL.
## Development
To get started with development, you need `yarn --optional` and `docker compose` installed (or know how to set up a PostgreSQL database).
### Note: `--optional` flag is **REQUIRED**
Drop uses a utility package called droplet that's written in Rust. It has builts for Linux (GNU) and Windows, and they are set up as optional packages. `npm` installs these by default, but `yarn` needs the `--optional` flag.
Steps:
1. Run `git submodule update --init --recursive` to setup submodules
1. Copy the `.env.example` to `.env` and add your GiantBomb metadata key (more metadata providers coming)
1. Create the `.data` directory with `mkdir .data`
1. Ensure that your user owns the `.data` directory with `sudo chown -R $(id -u $(whoami))`
1. Open up a terminal and navigate to `dev-tools`, and run `docker compose up`
1. Open up another terminal in the root directory of the project and run `yarn` and then `yarn dev` to start the dev server
As part of the first-time bootstrap, Drop creates an invitation with the fixed id of 'admin'. So, to create an admin account, go to:
http://localhost:3000/auth/register?id=admin
## Contributing
Please see the [in-depth contributing guide](CONTRIBUTING.md). The guide includes information on how to set up the project, how to contribute code, how to report issues, and even how to effectively translate Drop.
[![Drop Translation Progress](https://translate.droposs.org/widget/drop/horizontal-auto.svg)](https://translate.droposs.org/engage/drop/)
Please see the [in-depth contributing guide](CONTRIBUTING.md)

23
app.vue Normal file
View File

@ -0,0 +1,23 @@
<template>
<NuxtLoadingIndicator color="#2563eb" />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<ModalStack />
</template>
<script setup lang="ts">
await updateUser();
</script>
<style scoped>
/* You can customise the default animation here. */
::view-transition-old(root) {
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out;
}
::view-transition-new(root) {
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in;
}
</style>

View File

@ -1,65 +0,0 @@
<template>
<NuxtLoadingIndicator color="#2563eb" />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<ModalStack />
<div
v-if="showExternalUrlWarning"
class="fixed flex flex-row gap-x-2 right-0 bottom-0 m-2 px-2 py-2 z-50 text-right bg-red-700/90 rounded-lg"
>
<div class="flex flex-col">
<span class="text-sm text-zinc-200 font-bold font-display">{{
$t("errors.externalUrl.title")
}}</span>
<span class="text-xs text-red-400">{{
$t("errors.externalUrl.subtitle")
}}</span>
</div>
<button class="text-red-200" @click="() => hideExternalURL()">
<XMarkIcon class="size-5" />
</button>
</div>
</template>
<script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/24/outline";
await updateUser();
const user = useUser();
const apiDetails = await $dropFetch("/api/v1");
const showExternalUrlWarning = ref(false);
function checkExternalUrl() {
if (!import.meta.client) return;
const realOrigin = window.location.origin.trim();
const chosenOrigin = apiDetails.external.trim();
const ignore = window.localStorage.getItem("ignoreExternalUrl");
if (ignore && ignore == "true") return;
showExternalUrlWarning.value = !(realOrigin == chosenOrigin);
}
function hideExternalURL() {
window.localStorage.setItem("ignoreExternalUrl", "true");
showExternalUrlWarning.value = false;
}
if (user.value?.admin) {
onMounted(() => {
checkExternalUrl();
});
}
</script>
<style scoped>
/* You can customise the default animation here. */
::view-transition-old(root) {
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out;
}
::view-transition-new(root) {
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in;
}
</style>

View File

@ -1,13 +0,0 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@config "../../tailwind.config.js";
@layer base {
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button,
input[type="number"] {
-webkit-appearance: none;
-moz-appearance: textfield !important;
}
}

View File

@ -1,18 +0,0 @@
<template>
<div class="flex">
<a
:href="`/auth/oidc?redirect=${route.query.redirect ?? '/'}`"
class="transition rounded-md grow inline-flex items-center justify-center bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-white/20"
>
<i18n-t keypath="auth.signin.externalProvider" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</a>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
</script>

View File

@ -1,15 +0,0 @@
<template>
<img ref="emojiEl" class="inline-block emoji" :src="url" :alt="emoji" />
</template>
<script lang="ts" setup>
import twemoji from "@discordapp/twemoji";
const props = defineProps<{
emoji: string;
}>();
const url = computed(() => {
return `/twemoji/${twemoji.convert.toCodePoint(props.emoji)}.svg`;
});
</script>

View File

@ -1,285 +0,0 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game && unimportedVersions" class="p-8">
<div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">Versions</h1>
<p class="mt-2 text-sm text-zinc-400 max-w-lg">
Versions are a collection of files that are downloaded to clients.
Each version can have multiple configurations, for different
platforms.
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
:href="canImport ? `/admin/library/g/${game.id}/import` : ''"
type="button"
:class="[
canImport ? 'bg-blue-600 hover:bg-blue-700' : 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
>
{{
canImport
? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions")
}}
</NuxtLink>
</div>
</div>
<div class="mt-8 rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm">
<div>
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
Version Name
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Imported
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Platforms
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="version in game.versions"
:key="version.versionId"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ version.versionName }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime :date="version.created" />
</td>
<td class="px-3 py-4">
<ul class="space-y-4">
<li
v-for="gameVersion in version.gameVersions"
:key="gameVersion.versionId"
class="px-3 py-2 border border-zinc-800 rounded-lg shadow"
>
<div>
<div
class="text-sm flex items-center gap-x-2 text-zinc-200 font-semibold"
>
<IconsPlatform
:platform="
platforms[gameVersion.platformId].platformIcon.key
"
:fallback="
platforms[gameVersion.platformId].platformIcon
.fallback
"
class="size-5 text-blue-500"
/>
<span class="block truncate">{{
platforms[gameVersion.platformId].name
}}</span>
</div>
<!-- launch commands -->
<div class="space-y-1 mt-4">
<div
v-if="gameVersion.install"
class="flex items-center justify-between"
>
<span
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
>Install</span
>
<div
class="whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
>
<span class="text-zinc-700">(install dir)/</span
>{{ gameVersion.install.command }}
{{ gameVersion.install.args }}
</div>
</div>
<div>
<span class="font-semibold text-sm text-zinc-100"
>Launch options</span
>
<ul class="divide-y divide-zinc-700">
<li
v-for="launch in gameVersion.launches"
:key="launch.command"
class="ml-2 py-2 flex justify-between items-center"
>
<h1
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
>
{{ launch.name }}
</h1>
<div
class="mt-1 whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
>
<span class="text-zinc-700"
>(install dir)/</span
>{{ launch.command }} {{ launch.args }}
</div>
</li>
</ul>
</div>
<div
v-if="gameVersion.uninstall"
class="flex items-center justify-between"
>
<span
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
>Uninstall</span
>
<div
class="whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
>
<span class="text-zinc-700">(install dir)/</span
>{{ gameVersion.uninstall.command }}
{{ gameVersion.uninstall.args }}
</div>
</div>
</div>
</div>
</li>
</ul>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => deleteVersion(version.versionId)"
>
Delete
<span class="sr-only">
{{ $t("chars.srComma", [version.versionName]) }}
</span>
</button>
</td>
</tr>
<tr v-if="game.versions.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
No versions
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div v-else class="grow w-full flex items-center justify-center">
<div class="flex flex-col items-center">
<ExclamationCircleIcon
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">
{{ $t("library.admin.offlineTitle") }}
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-md">
{{ $t("library.admin.offline") }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { SerializeObject, TypedInternalResponse } from "nitropack";
import type { H3Error } from "h3";
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
// TODO implement UI for this page
const props = defineProps<{ unimportedVersions: string[] }>();
const { t } = useI18n();
const hasDeleted = ref(false);
const canImport = computed(
() => hasDeleted.value || props.unimportedVersions.length > 0,
);
type GameFetchType = TypedInternalResponse<
"/api/v1/admin/game/:id",
unknown,
"get"
>["game"];
const game = defineModel<SerializeObject<GameFetchType>>({ required: true });
if (!game.value)
throw createError({
statusCode: 500,
message: "Game not provided to editor component",
});
const rawPlatforms = await useAdminPlatforms();
const platforms = Object.fromEntries(
renderPlatforms(rawPlatforms).map((v) => [v.param, v]),
);
async function updateVersionOrder() {
try {
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
method: "PATCH",
body: {
id: game.value.id,
versions: game.value.versions.map((e) => e.versionId),
},
});
game.value.versions = newVersions;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.order.title"),
description: t("errors.version.order.desc", {
error: (e as H3Error)?.message ?? t("errors.unknown"),
}),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
async function deleteVersion(versionId: string) {
await $dropFetch("/api/v1/admin/game/version", {
method: "DELETE",
body: {
id: versionId,
},
failTitle: "Failed to delete version.",
});
game.value.versions.splice(
game.value.versions.findIndex((e) => e.versionId === versionId),
1,
);
hasDeleted.value = true;
}
</script>

View File

@ -1,104 +0,0 @@
<template>
<NuxtLink
v-if="game || defaultPlaceholder"
:href="href"
:class="{
'transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5':
animate,
}"
class="group relative flex-1 min-w-42 max-w-48 h-64 rounded-lg overflow-hidden"
>
<div
:class="{
'transition-all duration-300 group-hover:scale-110': animate,
}"
class="absolute inset-0"
>
<img
:src="imageProps.src"
class="w-full h-full object-cover brightness-[90%]"
:alt="imageProps.alt"
/>
<div
v-if="showTitleDescription"
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/0 to-transparent"
/>
</div>
<div
v-if="showTitleDescription"
class="absolute bottom-0 left-0 w-full p-3"
>
<h1
:class="{ 'group-hover:text-white transition-colors': animate }"
class="text-zinc-100 text-sm font-bold font-display"
>
{{
game ? game.mName : $t("settings.admin.store.dropGameNamePlaceholder")
}}
</h1>
<p
:class="{
'group-hover:text-zinc-300 transition-colors': animate,
}"
class="text-zinc-400 text-xs line-clamp-2"
>
{{
game
? game.mShortDescription
: $t("settings.admin.store.dropGameDescriptionPlaceholder")
}}
</p>
</div>
</NuxtLink>
<SkeletonCard
v-else-if="defaultPlaceholder === false"
:message="$t('store.noGame')"
/>
</template>
<script setup lang="ts">
import type { SerializeObject } from "nitropack";
const { t } = useI18n();
const {
game,
href = undefined,
showTitleDescription = true,
animate = true,
defaultPlaceholder = false,
} = defineProps<{
game:
| SerializeObject<{
id: string;
mCoverObjectId: string;
mName: string;
mShortDescription: string;
}>
| undefined
| null;
href?: string | undefined;
showTitleDescription?: boolean;
animate?: boolean;
defaultPlaceholder?: boolean;
}>();
const imageProps = {
src: "",
alt: t("settings.admin.store.dropGameAltPlaceholder"),
};
if (game) {
imageProps.src = useObject(game.mCoverObjectId);
imageProps.alt = game.mName;
} else if (defaultPlaceholder) {
imageProps.src = "/game-panel-placeholder.png";
}
</script>
<style scoped>
img.active {
view-transition-name: selected-game;
contain: layout;
}
</style>

View File

@ -1,26 +0,0 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<component
:is="platformIcons[props.platform as HardwarePlatform]"
v-if="platformIcons[props.platform as HardwarePlatform]"
/>
<div v-else-if="props.fallback" v-html="props.fallback" />
<DropLogo v-else />
</template>
<script setup lang="ts">
import { HardwarePlatform } from "~~/prisma/client/enums";
import type { Component } from "vue";
import LinuxLogo from "./LinuxLogo.vue";
import WindowsLogo from "./WindowsLogo.vue";
import MacLogo from "./MacLogo.vue";
import DropLogo from "../DropLogo.vue";
const props = defineProps<{ platform: string; fallback?: string | undefined }>();
const platformIcons: { [key in HardwarePlatform]: Component } = {
[HardwarePlatform.Linux]: LinuxLogo,
[HardwarePlatform.Windows]: WindowsLogo,
[HardwarePlatform.macOS]: MacLogo,
};
</script>

View File

@ -1,238 +0,0 @@
<template>
<div class="flex flex-col gap-y-4">
<!-- without metadata option -->
<div>
<LoadingButton
class="w-fit"
:loading="props.loading"
@click="() => importGame(false)"
>
{{ $t("library.admin.import.withoutMetadata") }}
</LoadingButton>
</div>
<!-- divider -->
<div
class="inline-flex items-center gap-x-4 text-zinc-600 font-display font-bold"
>
<div class="h-[1px] grow bg-zinc-800" />
{{ $t("auth.signin.or") }}
<div class="h-[1px] grow bg-zinc-800" />
</div>
<!-- with metadata option -->
<div class="flex flex-col gap-y-4">
<form @submit.prevent="() => searchGame()">
<label
for="searchTerm"
class="block text-sm/6 font-medium text-zinc-100"
>{{ $t("library.admin.import.search") }}</label
>
<div class="mt-2 flex">
<div class="-mr-px grid grow grid-cols-1 focus-within:relative">
<input
id="searchTerm"
v-model="gameSearchTerm"
type="text"
name="searchTerm"
class="col-start-1 row-start-1 block w-full rounded-l-md bg-zinc-950 py-1.5 px-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
:placeholder="$t('library.admin.import.searchPlaceholder')"
/>
</div>
<LoadingButton
:loading="gameSearchLoading"
:style="'none'"
type="submit"
class="w-24 flex shrink-0 items-center justify-center gap-x-1.5 rounded-r-md bg-zinc-950 px-3 py-2 text-sm font-semibold text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 hover:bg-zinc-900 focus:relative focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
>
<MagnifyingGlassIcon
class="-ml-0.5 size-4 text-gray-400"
aria-hidden="true"
/>
{{ $t("library.admin.import.search") }}
</LoadingButton>
</div>
</form>
<Listbox
v-if="metadataResults && metadataResults.length > 0"
v-model="model"
as="div"
>
<ListboxLabel
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.selectGameSearch") }}</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<GameSearchResultWidget v-if="model !== undefined" :game="model" />
<span v-else class="block truncate text-zinc-600">
{{ $t("library.admin.import.selectGamePlaceholder") }}
</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
v-for="result in metadataResults"
:key="result.id"
v-slot="{ active }"
as="template"
:value="result"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<GameSearchResultWidget :game="result" />
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div
v-else-if="gameSearchResultsLoading"
role="status"
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
>
{{ $t("library.admin.import.loading") }}
<svg
aria-hidden="true"
class="w-6 h-6 text-transparent animate-spin fill-white"
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>
</div>
<div
v-if="gameSearchResultsError"
class="w-fit 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">
{{ gameSearchResultsError }}
</h3>
</div>
</div>
</div>
<div>
<LoadingButton
class="w-fit"
:loading="props.loading"
:disabled="model === undefined"
@click="() => importGame()"
>
{{ $t("library.admin.import.import") }}
</LoadingButton>
<div
v-if="props.error"
class="mt-4 w-fit 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">
{{ props.error }}
</h3>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
const model = ref<GameMetadataSearchResult | undefined>(undefined);
const props = defineProps<{
gameName: string;
loading: boolean;
error?: string | undefined;
}>();
const emit = defineEmits<{
import: [metadata: GameMetadataSearchResult | undefined];
}>();
function importGame(metadata = true) {
emit("import", metadata ? model.value : undefined);
}
const metadataResults = ref<Array<GameMetadataSearchResult> | undefined>();
onMounted(() => {
if (!metadataResults.value) searchGame();
});
const gameSearchLoading = ref(false);
const gameSearchResultsLoading = ref(false);
const gameSearchResultsError = ref<string | undefined>();
const gameSearchTerm = ref(props.gameName);
async function searchGame() {
gameSearchResultsError.value = undefined;
gameSearchLoading.value = true;
try {
const results = await $dropFetch(
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
);
metadataResults.value = results;
gameSearchLoading.value = false;
} catch (e) {
gameSearchLoading.value = false;
throw e;
}
}
</script>

View File

@ -1,336 +0,0 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="flex flex-row gap-x-4">
<label
for="icon-upload"
class="relative size-24 bg-zinc-800 rounded-md overflow-hidden has-[:focus]:ring-2 has-[:focus]:ring-blue-600"
>
<input
id="icon-upload"
type="file"
class="sr-only"
accept="image/*"
@change="addFile"
/>
<img
v-if="currentFileObjectUrl"
:src="currentFileObjectUrl"
class="absolute inset-0 object-cover w-full h-full"
/>
<div
class="absolute inset-0 cursor-pointer flex flex-col gap-y-1 items-center justify-center text-zinc-300 bg-zinc-900/50"
>
<ArrowUpTrayIcon class="size-6" />
<span class="text-xs font-bold font-display uppercase">Upload</span>
</div>
</label>
<div class="grow flex flex-col gap-y-4">
<div>
<label for="name" class="block text-sm font-medium text-zinc-100"
>Name</label
>
<input
id="name"
v-model="name"
type="text"
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
<div>
<label for="description" class="block text-sm font-medium text-zinc-100"
>Description</label
>
<input
id="description"
v-model="description"
type="text"
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</div>
<SwitchGroup
as="div"
class="max-w-lg flex items-center justify-between gap-x-4"
>
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>Create as platform</SwitchLabel
>
<SwitchDescription as="span" class="text-sm text-zinc-400"
>Versions for this redistributable will be able to take a series of
launch commands. Intended to be used with emulators and similar
programs.</SwitchDescription
>
</span>
<Switch
v-model="isPlatform"
:class="[
isPlatform ? 'bg-blue-600' : 'bg-zinc-800',
'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 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
]"
>
<span
aria-hidden="true"
:class="[
isPlatform ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
/>
</Switch>
</SwitchGroup>
<div class="relative">
<div class="flex flex-row gap-x-4">
<label
for="platform-icon-upload"
class="relative size-24 bg-zinc-800 rounded-md overflow-hidden has-[:focus]:ring-2 has-[:focus]:ring-blue-600"
>
<input
id="platform-icon-upload"
type="file"
class="sr-only"
accept="image/svg+xml"
:disabled="!isPlatform"
@change="addSvg"
/>
<div
v-if="platform.icon"
class="absolute inset-0 object-cover w-full h-full text-blue-600"
v-html="platform.icon"
/>
<div
class="absolute inset-0 cursor-pointer flex flex-col gap-y-1 items-center justify-center text-zinc-300 bg-zinc-900/50 focus:text-zinc-100"
>
<ArrowUpTrayIcon class="size-6" />
<span class="text-xs font-bold font-display uppercase"
>Upload SVG</span
>
</div>
</label>
<div class="grow flex flex-col gap-y-4">
<div>
<label
for="platform-name"
class="block text-sm font-medium text-zinc-100"
>Platform Name</label
>
<input
id="platform-name"
v-model="platform.name"
type="text"
:disabled="!isPlatform"
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</div>
<div class="mt-2 w-full">
<label for="platform-name" class="block text-sm font-medium text-zinc-100"
>File Extensions {{ currentExtDotted }}
</label
>
<Combobox
as="div"
:model-value="currentExtDotted"
nullable
class="mt-1 w-full"
:disabled="!isPlatform"
@update:model-value="(v) => addExt(v)"
>
<div class="relative">
<ComboboxInput
class="w-full block flex-1 rounded-lg border-1 border-zinc-800 py-2 px-2 bg-zinc-950 text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder=".exe"
@change="currentExt = $event.target.value"
@blur="currentExt = ''"
/>
<ComboboxOptions
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-white/5 focus:outline-none sm:text-sm"
>
<ComboboxOption
v-if="currentExt"
v-slot="{ active }"
:value="currentExtDotted"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span class="block">
<span class="text-blue-300">filename</span
><span class="font-semibold">{{ currentExtDotted }}</span>
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
<div class="mt-2 flex gap-1 flex-wrap">
<div
v-for="ext in platform.fileExts"
:key="ext"
class="bg-blue-600/10 border-1 border-blue-700 rounded-full px-2 py-1 text-xs text-blue-400"
>
{{ ext }}
</div>
<span
v-if="platform.fileExts.length == 0"
class="uppercase font-display text-zinc-700 font-bold text-xs"
>No suggested file extensions.</span
>
</div>
</div>
<div v-if="!isPlatform" class="absolute inset-0 bg-zinc-950/20" />
</div>
<div>
<LoadingButton
class="w-fit"
:loading="props.loading"
:disabled="buttonDisabled"
@click="() => importRedist()"
>
{{ $t("library.admin.import.import") }}
</LoadingButton>
<div v-if="props.error" class="mt-4 w-fit 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">
{{ props.error }}
</h3>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
Switch,
SwitchDescription,
SwitchGroup,
SwitchLabel,
} from "@headlessui/vue";
import { ArrowUpTrayIcon } from "@heroicons/vue/24/outline";
const currentFile = ref<File | undefined>(undefined);
const currentFileObjectUrl = ref<string | undefined>(undefined);
const emit = defineEmits<{
import: [
metadata: { name: string; description: string; icon: File } | undefined,
platform: typeof platform.value | undefined,
];
}>();
const name = ref("");
const description = ref("");
const isPlatform = ref(false);
const currentExt = ref("");
const currentExtDotted = computed(() => {
if(!currentExt.value) return "";
const cleaned = currentExt.value.replace(/\W/g, "").toLowerCase();
return `.${cleaned}`;
});
const platform = ref<{ name: string; icon: string; fileExts: string[] }>({
name: "",
icon: "",
fileExts: [],
});
const buttonDisabled = computed<boolean>(
() =>
!(
name.value &&
description.value &&
currentFileObjectUrl.value &&
(!isPlatform.value || (platform.value.name && platform.value.icon))
),
);
function addFile(event: Event) {
const file = (event.target as HTMLInputElement)?.files?.[0];
if (!file) return;
if (currentFileObjectUrl.value) {
URL.revokeObjectURL(currentFileObjectUrl.value);
}
currentFile.value = file;
currentFileObjectUrl.value = URL.createObjectURL(file);
}
async function addSvg(event: Event) {
const file = (event.target as HTMLInputElement)?.files?.[0];
if (!file) return;
const svgContent = await file.text();
const parser = new DOMParser();
try {
const document = parser.parseFromString(svgContent, "image/svg+xml");
const svg = document.getElementsByTagName("svg").item(0);
if (!svg) throw "No SVG in uploaded image.";
svg.removeAttribute("width");
svg.removeAttribute("height");
platform.value.icon = svg.outerHTML;
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to upload SVG",
description: (e as string)?.toString() ?? e,
},
(_, c) => c(),
);
return;
}
}
function addExt(ext: string | null) {
if (!ext) return;
if (platform.value.fileExts.includes(ext)) return;
platform.value.fileExts.push(ext);
currentExt.value = "";
}
const props = defineProps<{
gameName: string;
loading: boolean;
error?: string | undefined;
}>();
function importRedist() {
if (!currentFile.value) return;
emit(
"import",
{
name: name.value,
description: description.value,
icon: currentFile.value,
},
isPlatform.value
? {
...platform.value,
fileExts: platform.value.fileExts.map((e) => e.slice(1)),
}
: undefined,
);
}
</script>

View File

@ -1,29 +0,0 @@
<template>
<div>
<LanguageSelectorListbox />
<NuxtLink
class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm"
to="https://translate.droposs.org/engage/drop/"
target="_blank"
>
<i18n-t
keypath="helpUsTranslate"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1 hover:underline"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
<DevOnly>
<h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
</DevOnly>
</div>
</template>
<script setup lang="ts">
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
</script>

View File

@ -1,155 +0,0 @@
<template>
<Listbox v-model="wiredLocale" as="div">
<ListboxLabel
v-if="showText"
class="block text-sm/6 font-medium text-zinc-400"
>{{ $t("selectLanguage") }}</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="grid w-full cursor-default grid-cols-1 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-300 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
>
<span class="col-start-1 row-start-1 flex items-center gap-3 pr-6">
<EmojiText
:emoji="localeToEmoji(wiredLocale)"
class="-mt-0.5 shrink-0 max-w-6"
/>
<span class="block truncate">{{
currentLocaleInformation?.name ?? wiredLocale
}}</span>
</span>
<ChevronUpDownIcon
class="col-start-1 row-start-1 size-5 self-center justify-self-end text-gray-500 sm:size-4"
aria-hidden="true"
/>
</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-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm"
>
<ListboxOption
v-for="listLocale in locales"
:key="listLocale.code"
v-slot="{ active, selected }"
as="template"
:value="listLocale.code"
>
<li
:class="[
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-300',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<div class="flex items-center">
<EmojiText
:emoji="localeToEmoji(listLocale.code)"
class="-mt-0.5 shrink-0 max-w-6"
/>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'ml-3 block truncate',
]"
>{{ listLocale.name }}</span
>
</div>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
import { CheckIcon } from "@heroicons/vue/24/outline";
import type { Locale } from "vue-i18n";
const { showText = true } = defineProps<{ showText?: boolean }>();
const { locale: currLocale, setLocale, locales } = useI18n();
function changeLocale(locale: Locale) {
setLocale(locale);
// dynamically update the HTML attributes for language and direction
// this is necessary for proper rendering of the page in the new language
useHead({
htmlAttrs: {
lang: locale,
dir: locales.value.find((l) => l.code === locale)?.dir || "ltr",
},
});
}
function localeToEmoji(local: string): string {
switch (local) {
// Default locale
case "en":
case "en-us":
return "🇺🇸";
case "en-gb":
return "🇬🇧";
case "en-ca":
return "🇨🇦";
case "en-au":
return "🇦🇺";
case "en-pirate":
return "🏴‍☠️";
case "fr":
return "🇫🇷";
case "de":
return "🇩🇪";
case "es":
return "🇪🇸";
case "it":
return "🇮🇹";
case "zh":
return "🇨🇳";
case "zh-tw":
return "🇹🇼";
default: {
return "❓";
}
}
}
const wiredLocale = computed({
get() {
return currLocale.value;
},
set(v) {
changeLocale(v);
},
});
const currentLocaleInformation = computed(() =>
locales.value.find((e) => e.code == wiredLocale.value),
);
</script>

View File

@ -1,27 +0,0 @@
<template>
<span class="text-xs font-mono text-zinc-400 inline-flex items-top gap-x-2"
><span v-if="!short" class="text-zinc-500">{{ log.timestamp }}</span>
<span
:class="[
colours[log.level] || 'text-green-400',
'uppercase font-display font-semibold',
]"
>{{ log.level }}</span
>
<pre :class="[short ? 'line-clamp-1' : '', 'mt-[1px]']">{{
log.message
}}</pre>
</span>
</template>
<script setup lang="ts">
import type { TaskLog } from "~~/server/internal/tasks";
defineProps<{ log: typeof TaskLog.infer; short?: boolean }>();
const colours: { [key: string]: string } = {
info: "text-blue-400",
warn: "text-yellow-400",
error: "text-red-400",
};
</script>

View File

@ -1,246 +0,0 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.companies.addGame.title") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.companies.addGame.description") }}
</p>
</div>
<div class="mt-2">
<form @submit.prevent="() => addGame()">
<Listbox v-model="currentGame" as="div">
<ListboxLabel
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.selectGameSearch") }}</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<GameSearchResultWidget
v-if="currentGame"
:game="currentGame"
/>
<span v-else class="block truncate text-zinc-600">
{{ $t("library.admin.import.selectGamePlaceholder") }}
</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
v-for="result in metadataGames"
:key="result.id"
v-slot="{ active }"
as="template"
:value="result"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<GameSearchResultWidget :game="result" />
</li>
</ListboxOption>
<p
v-if="metadataGames.length == 0"
class="w-full text-center p-2 uppercase font-display text-zinc-700 font-bold"
>
{{ $t("library.admin.metadata.companies.addGame.noGames") }}
</p>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div class="mt-6 flex items-center justify-between gap-3">
<label
id="published-label"
for="published"
class="font-medium text-md text-zinc-100"
>{{
$t("library.admin.metadata.companies.addGame.publisher")
}}</label
>
<div
class="group/published relative inline-flex w-11 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/published:translate-x-5"
/>
<input
id="published"
v-model="published"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="published-label"
/>
</div>
</div>
<div class="mt-2 flex items-center justify-between gap-3">
<label
id="developer-label"
for="developer"
class="font-medium text-md text-zinc-100"
>{{
$t("library.admin.metadata.companies.addGame.developer")
}}</label
>
<div
class="group/developer relative inline-flex w-11 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/developer:translate-x-5"
/>
<input
id="developer"
v-model="developed"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="developer-label"
/>
</div>
</div>
<button class="hidden" type="submit" />
</form>
</div>
<div v-if="addError" 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">
{{ addError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="addGameLoading"
:disabled="!(currentGame && (developed || published))"
class="w-full sm:w-fit"
@click="() => addGame()"
>
{{ $t("common.add") }}
</LoadingButton>
<button
ref="cancelButtonRef"
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="() => close()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { GameModel } from "~~/prisma/client/models";
import {
DialogTitle,
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
import { FetchError } from "ofetch";
import type { SerializeObject } from "nitropack";
import { XCircleIcon } from "@heroicons/vue/24/solid";
const props = defineProps<{
companyId: string;
exclude?: string[];
}>();
const emit = defineEmits<{
created: [
game: SerializeObject<GameModel>,
published: boolean,
developed: boolean,
];
}>();
const games = await $dropFetch("/api/v1/admin/game");
const metadataGames = computed(() =>
games
.filter((e) => !(props.exclude ?? []).includes(e.id))
.map(
(e) =>
({
id: e.id,
name: e.mName,
icon: useObject(e.mIconObjectId),
description: e.mShortDescription,
}) satisfies Omit<GameMetadataSearchResult, "year">,
),
);
const { t } = useI18n();
const open = defineModel<boolean>({ required: true });
const currentGame = ref<NonNullable<(typeof metadataGames.value)[number]> | null>(null);
const developed = ref(false);
const published = ref(false);
const addGameLoading = ref(false);
const addError = ref<string | undefined>(undefined);
async function addGame() {
if (!currentGame.value) return;
addGameLoading.value = true;
try {
const game = await $dropFetch("/api/v1/admin/company/:id/game", {
method: "POST",
params: { id: props.companyId },
body: {
id: currentGame.value.id,
developed: developed.value,
published: published.value,
},
});
emit("created", game, published.value, developed.value);
} catch (e) {
if (e instanceof FetchError) {
addError.value = e.message ?? t("errors.unknown");
} else {
throw e;
}
} finally {
currentGame.value = null;
developed.value = false;
published.value = false;
addGameLoading.value = false;
open.value = false;
}
}
</script>

View File

@ -1,148 +0,0 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<h3 class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.companies.modals.createTitle") }}
</h3>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.companies.modals.createDescription") }}
</p>
</div>
<div class="mt-2">
<form class="space-y-4" @submit.prevent="() => createCompany()">
<div>
<label
for="name"
class="block text-sm/6 font-medium text-zinc-100"
>{{
$t("library.admin.metadata.companies.modals.createFieldName")
}}</label
>
<div class="mt-2">
<input
id="name"
v-model="companyName"
type="text"
name="name"
:placeholder="
$t(
'library.admin.metadata.companies.modals.createFieldNamePlaceholder',
)
"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
<div>
<label
for="description"
class="block text-sm/6 font-medium text-zinc-100"
>{{
$t(
"library.admin.metadata.companies.modals.createFieldDescription",
)
}}</label
>
<div class="mt-2">
<input
id="description"
v-model="companyDescription"
type="text"
name="description"
:placeholder="
$t(
'library.admin.metadata.companies.modals.createFieldDescriptionPlaceholder',
)
"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
<div>
<label
for="website"
class="block text-sm/6 font-medium text-zinc-100"
>{{
$t("library.admin.metadata.companies.modals.createFieldWebsite")
}}</label
>
<div class="mt-2">
<input
id="website"
v-model="companyWebsite"
type="text"
name="website"
:placeholder="
$t(
'library.admin.metadata.companies.modals.createFieldWebsitePlaceholder',
)
"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
<button class="hidden" type="submit" />
</form>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="loading"
:disabled="!companyValid"
class="w-full sm:w-fit"
@click="() => createCompany()"
>
{{ $t("common.create") }}
</LoadingButton>
<button
ref="cancelButtonRef"
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="() => close()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import type { CompanyModel } from "~~/prisma/client/models";
const open = defineModel<boolean>({ required: true });
const emit = defineEmits<{
created: [company: CompanyModel];
}>();
const companyName = ref("");
const companyDescription = ref("");
const companyWebsite = ref("");
const loading = ref(false);
const companyValid = computed(
() => companyName.value && companyDescription.value,
);
async function createCompany() {
loading.value = true;
try {
const newCompany = await $dropFetch("/api/v1/admin/company", {
method: "POST",
body: {
name: companyName.value,
description: companyDescription.value,
website: companyWebsite.value,
},
failTitle: "Failed to create new company",
});
open.value = false;
emit("created", newCompany);
} finally {
/* empty */
}
loading.value = false;
}
</script>

View File

@ -1,78 +0,0 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.tags.modal.title") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.tags.modal.description") }}
</p>
</div>
<div class="mt-2">
<form @submit.prevent="() => createTag()">
<input
v-model="tagName"
type="text"
class="block w-full rounded-md border-0 bg-zinc-800 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
<button class="hidden" type="submit" />
</form>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="createTagLoading"
:disabled="!tagName"
class="w-full sm:w-fit"
@click="() => createTag()"
>
{{ $t("common.create") }}
</LoadingButton>
<button
ref="cancelButtonRef"
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="() => close()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { DialogTitle } from "@headlessui/vue";
import type { GameTagModel } from "~~/prisma/client/models";
const emit = defineEmits<{
created: [tag: GameTagModel];
}>();
const open = defineModel<boolean>({ required: true });
const tagName = ref("");
const createTagLoading = ref(false);
async function createTag() {
if (!tagName.value || createTagLoading.value) return;
createTagLoading.value = true;
// Create the collection
const tag = await $dropFetch("/api/v1/admin/tags", {
method: "POST",
body: { name: tagName.value },
failTitle: "Failed to create tag",
});
// Reset and emit
tagName.value = "";
open.value = false;
emit("created", tag);
createTagLoading.value = false;
}
</script>

View File

@ -1,267 +0,0 @@
<template>
<ModalTemplate v-model="model" size-class="max-w-3xl">
<template #default>
<div class="space-y-5">
<div>
<label
for="name"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("account.token.name") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("account.token.nameDesc") }}
</p>
<div class="mt-2">
<input
id="name"
v-model="name"
name="name"
type="text"
autocomplete="name"
:placeholder="
props.suggestedName ?? $t('account.token.namePlaceholder')
"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<Listbox v-model="expiryKey" as="div">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100">{{
$t("users.admin.simple.inviteExpiryLabel")
}}</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">{{ expiryKey }}</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
v-for="[label] in Object.entries(expiry)"
:key="label"
v-slot="{ active, selected }"
as="template"
:value="label"
>
<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',
]"
>{{ label }}</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>
<label
for="name"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("account.token.acls") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("account.token.aclsDesc") }}
</p>
<fieldset class="divide-y divide-zinc-700">
<div
v-for="[sectionName, sectionAcls] in Object.entries(
aclsBySection,
)"
:key="sectionName"
class="grid lg:grid-cols-3 gap-1 py-3"
>
<div
v-for="[acl, description] in Object.entries(sectionAcls)"
:key="acl"
class="flex gap-3"
>
<div class="flex h-6 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
id="acl"
v-model="currentACLs[acl]"
aria-describedby="acl-description"
name="acl"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 border-white/10 bg-white/5 dark:checked:border-blue-500 dark:checked:bg-blue-500 dark:indeterminate:border-blue-500 dark:indeterminate:bg-blue-500 dark:focus-visible:outline-blue-500 dark:disabled:border-white/5 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10 forced-colors:appearance-auto"
/>
<svg
class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-white/25"
viewBox="0 0 14 14"
fill="none"
>
<path
class="opacity-0 group-has-checked:opacity-100"
d="M3 8L6 11L11 3.5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
class="opacity-0 group-has-indeterminate:opacity-100"
d="M3 7H11"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
<div class="text-sm/6">
<label
for="acl"
class="font-display font-medium text-white"
>{{ acl }}</label
>
{{ " " }}
<span id="acl-description" class="text-xs text-zinc-400"
><span class="sr-only">{{ acl }} </span
>{{ description }}</span
>
</div>
</div>
</div>
</fieldset>
</div>
</div>
</template>
<template #buttons>
<LoadingButton :loading="props.loading" @click="() => createToken()">
{{ $t("common.create") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => cancel()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
import type { DurationLike } from "luxon";
// Reuse for both admin and user tokens
const model = defineModel<boolean>({ required: true });
const { t } = useI18n();
const props = defineProps<{
acls: { [key: string]: string };
loading?: boolean;
suggestedAcls?: string[];
suggestedName?: string;
}>();
// Label to parameters to moment.js .add()
const expiry: Record<string, DurationLike | undefined> = {
[t("account.token.expiryMonth")]: {
month: 1,
},
[t("account.token.expiry3Month")]: {
month: 3,
},
[t("account.token.expiry6Month")]: {
month: 6,
},
[t("account.token.expiryYear")]: {
year: 1,
},
[t("account.token.expiry5Year")]: {
year: 5,
},
[t("account.token.noExpiry")]: undefined,
};
const expiryKey = ref<keyof typeof expiry>(Object.keys(expiry)[0]); // Cast to any because we just know it's okay
const name = ref(props.suggestedName ?? "");
const currentACLs = ref<{ [key: string]: boolean }>(
Object.fromEntries((props.suggestedAcls ?? []).map((v) => [v, true])),
);
const aclsBySection = computed(() => {
const sections: { [key: string]: { [key: string]: string } } = {};
for (const [acl, description] of Object.entries(props.acls)) {
const section = acl.split(":")[0];
sections[section] ??= {};
sections[section][acl] = description;
}
return sections;
});
const emit = defineEmits<{
create: [name: string, acls: string[], expiry: DurationLike | undefined];
}>();
function createToken() {
emit(
"create",
name.value,
Object.entries(currentACLs.value)
.filter(([_acl, enabled]) => enabled)
.map(([acl, _enabled]) => acl),
expiry[expiryKey.value],
);
}
function cancel() {
model.value = false;
}
watch(model, (c) => {
if (!c) {
name.value = "";
currentACLs.value = {};
}
});
</script>

View File

@ -1,75 +0,0 @@
<template>
<ModalTemplate :model-value="!!user">
<template #default>
<div>
<DialogTitle
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
{{ $t("users.admin.deleteUser", [user?.username]) }}
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
{{ $t("common.deleteConfirm", [user?.username]) }}
</p>
<p class="mt-2 text-sm font-bold text-red-500">
{{ $t("common.cannotUndo") }}
</p>
</div>
</template>
<template #buttons>
<LoadingButton
:loading="deleteLoading"
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteUser()"
>
{{ $t("delete") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (user = undefined)"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { DialogTitle } from "@headlessui/vue";
import type { UserModel } from "~~/prisma/client/models";
const user = defineModel<UserModel | undefined>();
const deleteLoading = ref(false);
const router = useRouter();
const { t } = useI18n();
async function deleteUser() {
try {
if (!user.value) return;
deleteLoading.value = true;
await $dropFetch(`/api/v1/admin/users/${user.value.id}`, {
method: "DELETE",
});
user.value = undefined;
await fetchUsers();
router.push("/admin/users");
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.admin.user.delete.title"),
description: t("errors.admin.user.delete.desc", [
// @ts-expect-error attempt to display message on error
e?.message ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
} finally {
deleteLoading.value = false;
}
}
</script>

View File

@ -1,115 +0,0 @@
<template>
<div>
<div class="inline-flex gap-1 items-center flex-wrap">
<span
v-for="item in enabledItems"
:key="item.param"
class="inline-flex items-center gap-x-0.5 rounded-md bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-500 ring-1 ring-blue-800 ring-inset"
>
{{ item.name }}
<button
type="button"
class="group relative -mr-1 size-3.5 rounded-xs hover:bg-blue-600/20"
@click="() => remove(item.param)"
>
<span class="sr-only">{{ $t("common.remove") }}</span>
<svg
viewBox="0 0 14 14"
class="size-3.5 stroke-blue-500 group-hover:stroke-blue-400"
>
<path d="M4 4l6 6m0-6l-6 6" />
</svg>
<span class="absolute -inset-1" />
</button>
</span>
<span
v-if="enabledItems.length == 0"
class="font-display uppercase text-xs font-bold text-zinc-700"
>
{{ $t("common.noSelected") }}
</span>
</div>
<Combobox as="div" @update:model-value="add">
<div class="relative mt-2">
<ComboboxInput
class="block w-full rounded-md bg-zinc-900 py-1.5 pr-12 pl-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-500 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
:display-value="(item) => (item as StoreSortOption)?.name"
placeholder="Start typing..."
@change="search = $event.target.value"
@blur="search = ''"
/>
<ComboboxButton
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden"
>
<ChevronDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<ComboboxOptions
v-if="filteredItems.length > 0 || search.length > 0"
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
>
<ComboboxOption
v-for="item in filteredItems"
:key="item.param"
v-slot="{ active }"
:value="item.param"
as="template"
>
<li
:class="[
'relative cursor-default py-2 pr-9 pl-3 select-none',
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-100',
]"
>
<span class="block truncate">
{{ item.name }}
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon } from "@heroicons/vue/20/solid";
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
const props = defineProps<{
items: Array<StoreSortOption>;
}>();
const model = defineModel<{ [key: string]: boolean }>();
const search = ref("");
const filteredItems = computed(() =>
props.items.filter(
(item) =>
!model.value?.[item.param] &&
item.name.toLowerCase().includes(search.value.toLowerCase()),
),
);
const enabledItems = computed(() =>
props.items.filter((e) => model.value?.[e.param]),
);
function add(item: string) {
search.value = "";
model.value ??= {};
model.value[item] = true;
}
function remove(item: string) {
model.value ??= {};
model.value[item] = false;
}
</script>

View File

@ -1,22 +0,0 @@
<template>
<div
:class="[
'transition border border-3 rounded-xl relative cursor-pointer',
active ? 'border-blue-600' : 'border-zinc-700',
]"
>
<div v-if="active" class="absolute top-1 right-1 z-1">
<CheckIcon
class="rounded-full p-1.5 bg-blue-600 size-6 text-transparent stroke-3 stroke-zinc-900 font-bold"
/>
</div>
<slot />
</div>
</template>
<script setup lang="ts">
import { CheckIcon } from "@heroicons/vue/24/solid";
const { active = false } = defineProps<{ active?: boolean }>();
</script>

View File

@ -1,120 +0,0 @@
<template>
<Combobox
as="div"
:value="props.value"
nullable
@update:model-value="(v) => emit('update', v)"
>
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="file.exe"
@change="query = $event.target.value"
@blur="query = ''"
/>
<ComboboxButton
v-if="filtered?.length ?? 0 > 0"
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
>
<ChevronUpDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<ComboboxOptions
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-white/5 focus:outline-none sm:text-sm"
>
<ComboboxOption
v-for="guess in filtered"
:key="guess.filename"
v-slot="{ active, selected }"
:value="guess.filename"
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active ? 'bg-blue-600 text-white outline-none' : 'text-zinc-100',
]"
>
<span
:class="[
'inline-flex items-center gap-x-2 block truncate',
selected && 'font-semibold',
]"
>
{{ guess.filename }}
<IconsPlatform
:platform="guess.platform.platformIcon.key"
:fallback="guess.platform.platformIcon.fallback"
class="size-5 flex-shrink-0 text-blue-600"
/>
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
<ComboboxOption
v-if="query"
v-slot="{ active, selected }"
:value="query"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active ? 'bg-blue-600 text-white outline-none' : 'text-zinc-100',
]"
>
<span :class="['block truncate', selected && 'font-semibold']">
{{ $t("chars.quoted", { text: query }) }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</template>
<script setup lang="ts">
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
const emit = defineEmits<{
update: [v: string];
}>();
const props = defineProps<{
value?: string | undefined;
guesses?: Array<{ platform: PlatformRenderable; filename: string }>;
}>();
const query = ref("");
const filtered = computed(() =>
props.guesses?.filter((e) =>
e.filename.toLowerCase().includes(query.value.toLowerCase()),
),
);
</script>

View File

@ -1,14 +0,0 @@
<template>
<div>{{ model }}</div>
</template>
<script setup lang="ts">
import type { SerializeObject } from "nitropack";
import type { RedistModel, UserPlatformModel } from "~~/prisma/client/models";
type ModelType = SerializeObject<
RedistModel & { platform?: UserPlatformModel }
>;
const model = defineModel<ModelType>({ required: true });
</script>

View File

@ -1,32 +0,0 @@
<template>
<div class="relative inline-block group/relative-time">
<!-- Visible relative time -->
<time :datetime="isoDate" class="text-sm text-muted-foreground">
{{ DateTime.fromJSDate(date).toRelative({ locale: $i18n.locale }) }}
</time>
<!-- Custom tooltip that shows on hover -->
<div
role="tooltip"
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 rounded bg-zinc-900 text-white text-xs whitespace-nowrap shadow z-10 opacity-0 group-hover/relative-time:opacity-100 transition-opacity pointer-events-none"
aria-hidden="true"
>
{{ $d(date, "long") }}
</div>
</div>
</template>
<script setup lang="ts">
import { DateTime } from "luxon";
import { computed } from "vue";
const props = defineProps<{
date: string | Date;
}>();
const date = computed(() =>
typeof props.date === "string" ? new Date(props.date) : props.date,
);
const isoDate = computed(() => date.value.toISOString());
</script>

View File

@ -1,159 +0,0 @@
<template>
<div class="p-2 lg:p-4">
<div class="px-4 py-2 max-w-xl">
<h1 class="font-semibold text-zinc-100 text-xl">
{{ $t("setup.auth.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("setup.auth.description") }}
</p>
</div>
<div class="grid lg:grid-cols-2 xl:grid-cols-3 h-fit p-4 gap-4">
<div class="p-4 border-1 border-zinc-800 rounded-xl">
<div>
<h1 class="text-zinc-100 font-semibold text-lg">
{{ $t("setup.auth.simple.title") }}
</h1>
<p class="text-sm text-zinc-400">
{{ $t("setup.auth.simple.description") }}
</p>
<NuxtLink
class="mt-4 rounded-md inline-flex items-center text-sm font-semibold text-blue-500 hover:text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
href="https://docs.droposs.org/docs/authentication/simple"
target="_blank"
>
<i18n-t
keypath="setup.auth.docs"
tag="span"
class="inline-flex items-center gap-x-1"
scope="global"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</div>
<div class="mt-4">
<div class="w-full flex justify-between items-center">
<span class="text-zinc-100 font-semibold text-sm">{{
$t("setup.auth.enabled")
}}</span>
<CheckIcon
v-if="enabledAuth.Simple"
class="size-5 text-green-600"
/>
<XMarkIcon v-else class="size-5 text-red-600" />
</div>
<LoadingButton
class="mt-4"
:loading="invitationLoading"
:disabled="!enabledAuth.Simple"
@click="() => registerAsAdmin()"
>
<i18n-t
keypath="setup.auth.simple.register"
tag="span"
class="inline-flex items-center gap-x-1"
scope="global"
>
<template #arrow>
{{ $t("chars.arrow") }}
</template>
</i18n-t>
</LoadingButton>
</div>
</div>
<div class="p-4 border-1 border-zinc-800 rounded-xl">
<div>
<h1 class="text-zinc-100 font-semibold text-lg">
{{ $t("setup.auth.openid.title") }}
</h1>
<p class="text-sm text-zinc-400">
{{ $t("setup.auth.openid.description") }}
</p>
<NuxtLink
class="mt-4 rounded-md inline-flex items-center text-sm font-semibold text-blue-500 hover:text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
href="https://docs.droposs.org/docs/authentication/oidc"
target="_blank"
>
<i18n-t
keypath="setup.auth.docs"
tag="span"
class="inline-flex items-center gap-x-1"
scope="global"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</div>
<div class="mt-4">
<div class="w-full flex justify-between items-center">
<span class="text-zinc-100 font-semibold text-sm">{{
$t("setup.auth.enabled")
}}</span>
<CheckIcon
v-if="enabledAuth.OpenID"
class="size-5 text-green-600"
/>
<XMarkIcon v-else class="size-5 text-red-600" />
</div>
<LoadingButton
class="mt-4"
:loading="false"
:disabled="!enabledAuth.OpenID"
@click="() => (complete = true)"
>
<i18n-t
keypath="setup.auth.openid.skip"
tag="span"
class="inline-flex items-center gap-x-1"
scope="global"
>
<template #arrow>
{{ $t("chars.arrow") }}
</template>
</i18n-t>
</LoadingButton>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ArrowTopRightOnSquareIcon,
CheckIcon,
XMarkIcon,
} from "@heroicons/vue/24/outline";
import { DateTime } from "luxon";
const complete = defineModel<boolean>({ required: true });
const { token } = defineProps<{ token: string }>();
const invitationLoading = ref(false);
const enabledAuth = await $dropFetch("/api/v1/admin/auth", {
headers: { Authorization: token },
});
async function registerAsAdmin() {
invitationLoading.value = true;
const expiryDate = DateTime.now().plus({ year: 5000 }).toJSON();
const invitation = await $dropFetch("/api/v1/admin/auth/invitation", {
method: "POST",
body: { isAdmin: true, expires: expiryDate },
headers: { Authorization: token },
failTitle: "Failed to create admin invitation",
});
window.open(`${invitation.inviteUrl}&after=close`, "_blank")?.focus();
invitationLoading.value = false;
complete.value = true;
}
</script>

View File

@ -1,15 +0,0 @@
<template>
<div class="p-8">
<AdminSourcesPage :token="token" />
</div>
</template>
<script setup lang="ts">
import AdminSourcesPage from "~/pages/admin/library/sources/index.vue";
const complete = defineModel<boolean>({ required: true });
// Only runs on component load, so it's fine
complete.value = true;
const { token } = defineProps<{ token: string }>();
</script>

View File

@ -1,27 +0,0 @@
<template>
<div>
<label
for="path"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.sources.fsPath") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.fsPathDesc") }}
</p>
<div class="mt-2">
<input
id="path"
v-model="model!.baseDir"
name="path"
type="text"
autocomplete="path"
:placeholder="$t('library.admin.sources.fsPathPlaceholder')"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</template>
<script setup lang="ts">
const model = defineModel<{ baseDir: string }>();
</script>

View File

@ -1,27 +0,0 @@
<template>
<div>
<label
for="path"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.sources.fsPath") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.fsPathDesc") }}
</p>
<div class="mt-2">
<input
id="path"
v-model="model!.baseDir"
name="path"
type="text"
autocomplete="path"
:placeholder="$t('library.admin.sources.fsPathPlaceholder')"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</template>
<script setup lang="ts">
const model = defineModel<{ baseDir: string }>();
</script>

View File

@ -1,496 +0,0 @@
<template>
<div>
<div>
<!-- Mobile filter dialog -->
<TransitionRoot as="template" :show="mobileFiltersOpen">
<Dialog
class="relative z-100 lg:hidden"
@close="mobileFiltersOpen = false"
>
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/25" />
</TransitionChild>
<div class="fixed inset-0 z-40 flex">
<TransitionChild
as="template"
enter="transition ease-in-out duration-300 transform"
enter-from="translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave-from="translate-x-0"
leave-to="translate-x-full"
>
<DialogPanel
class="relative ml-auto flex size-full max-w-sm flex-col overflow-y-auto bg-zinc-900 pt-4 pb-6 shadow-xl"
>
<div class="flex items-center justify-between px-4">
<h2 class="text-lg font-medium text-zinc-100">
{{ $t("store.view.srFilters") }}
</h2>
<button
type="button"
class="relative -mr-2 flex size-10 items-center justify-center rounded-md bg-zinc-900 p-2 text-zinc-500 hover:bg-zinc-800 focus:ring-2 focus:ring-blue-500 focus:outline-hidden"
@click="mobileFiltersOpen = false"
>
<span class="absolute -inset-0.5" />
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-6" aria-hidden="true" />
</button>
</div>
<!-- Filters -->
<form class="mt-4 border-t border-zinc-700">
<Disclosure
v-for="section in options"
v-slot="{ open }"
:key="section.param"
as="div"
class="border-t border-zinc-700 px-4 py-6"
>
<h3 class="-mx-2 -my-3 flow-root">
<DisclosureButton
class="flex w-full items-center justify-between bg-zinc-900 px-2 py-3 text-zinc-500 hover:text-zinc-400"
>
<span class="font-medium text-zinc-100">{{
section.name
}}</span>
<span class="ml-6 flex items-center">
<PlusIcon
v-if="!open"
class="size-5"
aria-hidden="true"
/>
<MinusIcon v-else class="size-5" aria-hidden="true" />
</span>
</DisclosureButton>
</h3>
<DisclosurePanel class="pt-6">
<div
v-if="section.options.length <= 10"
class="gap-3 grid grid-cols-2"
>
<div
v-for="(option, optionIdx) in section.options"
:key="option.param"
class="flex gap-3"
>
<div class="flex h-5 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
v-if="section.multiple"
:id="`filter-${section.param}-${option}`"
v-model="
(optionValues[section.param] as any)[
option.param
]
"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-900 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
/>
<input
v-else
:id="`filter-${section.param}`"
:value="optionValues[section.param]"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
@update:value="
() =>
(optionValues[section.param] = option.param)
"
/>
</div>
</div>
<label
:for="`filter-mobile-${section.param}-${optionIdx}`"
class="min-w-0 flex-1 text-zinc-400"
>{{ option.name }}</label
>
</div>
</div>
<MultiItemSelector
v-else
v-model="[optionValues[section.param] as any][0]"
:items="section.options"
/>
</DisclosurePanel>
</Disclosure>
</form>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
<main class="mx-auto px-4 sm:px-6 lg:px-8">
<div
class="flex items-baseline justify-between border-b border-zinc-700 py-6"
>
<div />
<div class="flex items-center">
<Menu as="div" class="relative inline-block text-left">
<div>
<MenuButton
class="group inline-flex justify-center text-sm font-medium text-zinc-400 hover:text-zinc-100"
>
{{ $t("store.view.sort") }}
<ChevronDownIcon
class="-mr-1 ml-1 size-5 shrink-0 text-gray-400 group-hover:text-zinc-100"
aria-hidden="true"
/>
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-zinc-950 shadow-2xl ring-1 ring-white/5 focus:outline-hidden"
>
<div class="py-1">
<MenuItem
v-for="option in sorts"
:key="option.param"
v-slot="{ active }"
>
<button
:class="[
currentSort == option.param
? 'font-medium text-zinc-100'
: 'text-zinc-400',
active ? 'bg-zinc-900 outline-hidden' : '',
'w-full text-left block px-4 py-2 text-sm',
]"
@click="() => (currentSort = option.param)"
>
{{ option.name }}
</button>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
<button
v-if="false"
type="button"
class="-m-2 ml-5 p-2 text-zinc-500 hover:text-zinc-400 sm:ml-7"
>
<span class="sr-only">{{ $t("store.view.srViewGrid") }}</span>
<Squares2X2Icon class="size-5" aria-hidden="true" />
</button>
<button
type="button"
:class="[
'-m-2 ml-4 p-2 sm:ml-6 lg:hidden',
filterQuery
? 'text-zinc-100 hover:text-zinc-200'
: 'text-zinc-500 hover:text-zinc-400',
]"
@click="mobileFiltersOpen = true"
>
<span class="sr-only"> {{ $t("store.view.srFilters") }} </span>
<FunnelIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
<section aria-labelledby="games-heading" class="pt-6 pb-24">
<h2 id="games-heading" class="sr-only">
{{ $t("store.view.srGames") }}
</h2>
<div class="grid grid-cols-1 gap-x-8 gap-y-10 lg:grid-cols-5">
<!-- Filters -->
<form class="hidden lg:block">
<Disclosure
v-for="section in options"
:key="section.param"
v-slot="{ open }"
as="div"
class="border-b border-zinc-700 py-6"
>
<h3 class="-my-3 flow-root">
<DisclosureButton
class="flex w-full items-center justify-between bg-zinc-900 py-3 text-sm text-zinc-500 hover:text-zinc-400"
>
<span class="font-medium text-zinc-100">{{
section.name
}}</span>
<span class="ml-6 flex items-center">
<PlusIcon
v-if="!open"
class="size-5"
aria-hidden="true"
/>
<MinusIcon v-else class="size-5" aria-hidden="true" />
</span>
</DisclosureButton>
</h3>
<DisclosurePanel class="pt-6">
<div v-if="section.options.length <= 10" class="space-y-4">
<div
v-for="(option, optionIdx) in section.options"
:key="option.param"
class="flex items-center gap-3"
>
<div class="flex h-5 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
v-if="section.multiple"
:id="`filter-${section.param}-${optionIdx}`"
v-model="
(optionValues[section.param] as any)[option.param]
"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
/>
<input
v-else
:id="`filter-${section.param}-${optionIdx}`"
:value="optionValues[section.param]"
:name="`${section.param}[]`"
type="radio"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
@input="optionValues[section.param] = option.param"
/>
</div>
</div>
<IconsPlatform
v-if="option.platformIcon"
:platform="option.platformIcon.key"
:fallback="option.platformIcon.fallback"
class="size-5 text-blue-500"
/>
<label
:for="`filter-${section.param}-${optionIdx}`"
class="text-sm text-zinc-400"
>{{ option.name }}</label
>
</div>
</div>
<MultiItemSelector
v-else
v-model="[optionValues[section.param] as any][0]"
:items="section.options"
/>
</DisclosurePanel>
</Disclosure>
</form>
<!-- Product grid -->
<div
v-if="games?.length ?? 0 > 0"
ref="product-grid"
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-7 gap-4"
>
<!-- Your content -->
<GamePanel
v-for="game in games"
:key="game.id"
:game="game"
:href="`/store/${game.id}`"
:show-title-description="showGamePanelTextDecoration"
/>
<div
v-if="loading"
class="absolute inset-0 bg-zinc-900/40 flex items-center justify-center"
>
<svg
aria-hidden="true"
class="w-8 h-8 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>
</div>
</div>
<div v-else class="flex lg:col-span-4 items-start justify-center">
<span class="uppercase text-zinc-700 font-display font-bold">{{
$t("common.noResults")
}}</span>
</div>
</div>
</section>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import {
Dialog,
DialogPanel,
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import { XMarkIcon } from "@heroicons/vue/24/outline";
import {
ChevronDownIcon,
FunnelIcon,
MinusIcon,
PlusIcon,
Squares2X2Icon,
} from "@heroicons/vue/20/solid";
import type { SerializeObject } from "nitropack";
import type { GameModel, GameTagModel } from "~~/prisma/client/models";
import MultiItemSelector from "./MultiItemSelector.vue";
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
const mobileFiltersOpen = ref(false);
const props = defineProps<{
params?: { [key: string]: string };
extraOptions?: Array<StoreFilterOption>;
prefilled?: {
[key: string]: { [key: string]: string | { [key: string]: boolean } };
};
}>();
const tags =
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/store/tags");
const userPlatforms = await $dropFetch("/api/v1/store/platforms");
const sorts: Array<StoreSortOption> = [
{
name: "Default",
param: "default",
},
{
name: "Newest",
param: "newest",
},
{
name: "Recently Added",
param: "recent",
},
];
const currentSort = ref(sorts[0].param);
const options: Array<StoreFilterOption> = [
...(tags.length > 0
? [
{
name: "Tags",
param: "tags",
multiple: true,
options: tags.map((e) => ({ name: e.name, param: e.id })),
},
]
: []),
{
name: "Platform",
param: "platform",
multiple: true,
options: renderPlatforms(userPlatforms),
},
...(props.extraOptions ?? []),
];
const optionValues = ref<{
[key: string]: string | undefined | { [key: string]: boolean | undefined };
}>(
Object.fromEntries(
options.map((v) => [v.param, v.multiple ? {} : undefined]),
),
);
Object.assign(optionValues.value, props.prefilled);
const filterQuery = computed(() => {
const query = Object.entries(optionValues.value)
.filter(
([_, v]) =>
v &&
(typeof v !== "object" || Object.values(v).filter((e) => e).length > 0),
)
.map(([n, v]) => {
if (typeof v === "string") return [`${n}=${v}`];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const enabledOptions = Object.entries(v as any).filter(([_, e]) => e);
return `${n}=${enabledOptions.map(([k, _]) => k).join(",")}`;
})
.join("&");
const extraFilters = props.params
? Object.entries(props.params)
.map(([k, v]) => `${k}=${v}`)
.join("&")
: props.params;
return `${query}${extraFilters ? (query ? "&" : "") + extraFilters : ""}`;
});
const games = ref<Array<SerializeObject<GameModel>>>();
const loading = ref(false);
const productGrid = useTemplateRef<HTMLElement>("product-grid");
const { reset } = useInfiniteScroll(
productGrid,
async () => await updateGames(filterQuery.value, false),
{
distance: 10,
canLoadMore: () => {
return canLoadMore.value;
},
},
);
const canLoadMore = ref(true);
async function updateGames(query: string, resetGames: boolean) {
loading.value = true;
games.value ??= [];
const newValues = await $dropFetch<{
results: Array<SerializeObject<GameModel>>;
count: number;
}>(
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}${query ? "&" + query : ""}`,
);
if (resetGames) {
games.value = newValues.results;
if (import.meta.client) await reset();
} else {
games.value.push(...newValues.results);
}
canLoadMore.value = games.value.length < newValues.count;
loading.value = false;
}
watch(filterQuery, (newUrl) => {
updateGames(newUrl, true);
});
watch(currentSort, (_) => {
updateGames(filterQuery.value, true);
});
await updateGames(filterQuery.value, true);
</script>

View File

@ -1,55 +0,0 @@
<template>
<div
v-if="task"
class="flex w-full items-center justify-between space-x-6 p-6"
>
<div class="flex-1 truncate">
<div class="flex items-center space-x-1">
<div>
<CheckCircleIcon v-if="task.success" class="size-5 text-green-600" />
<XMarkIcon v-else-if="task.error" class="size-5 text-red-600" />
<div
v-else
class="size-2 bg-blue-600 rounded-full animate-pulse m-1"
/>
</div>
<h3 class="truncate text-sm font-medium text-zinc-100">
{{ task.name }}
</h3>
</div>
<div
v-if="active"
class="mt-2 w-full rounded-full overflow-hidden bg-zinc-900"
>
<div
:style="{ width: `${task.progress}%` }"
class="bg-blue-600 h-[3px] transition-all"
/>
</div>
<div class="mt-2 bg-zinc-950 px-2 pb-1 rounded-sm">
<LogLine :short="true" :log="parseTaskLog(task.log.at(-1))" />
</div>
<NuxtLink
type="button"
:href="`/admin/task/${task.id}`"
class="mt-3 ml-1 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
>
<i18n-t keypath="tasks.admin.viewTask" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div v-else>
<!-- renders server side when we don't want to access the current tasks -->
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/24/solid";
import type { TaskMessage } from "~~/server/internal/tasks";
defineProps<{ task: TaskMessage | undefined; active?: boolean }>();
</script>

View File

@ -1,36 +0,0 @@
import type { UserPlatform } from "~~/prisma/client/client";
import { HardwarePlatform } from "~~/prisma/client/enums";
export type PlatformRenderable = {
name: string;
param: string;
platformIcon: { key: string; fallback?: string };
};
export function renderPlatforms(
userPlatforms: { platformName: string; id: string; iconSvg: string }[],
): PlatformRenderable[] {
return [
...Object.values(HardwarePlatform).map((e) => ({
name: e,
param: e,
platformIcon: { key: e },
})),
...userPlatforms.map((e) => ({
name: e.platformName,
param: e.id,
platformIcon: { key: e.id, fallback: e.iconSvg },
})),
];
}
const rawUseAdminPlatforms = () => useState<Array<UserPlatform> | null>('adminPlatforms', () => null);
export async function useAdminPlatforms() {
const platforms = rawUseAdminPlatforms();
if(platforms.value === null){
platforms.value = await $dropFetch("/api/v1/admin/platforms");
}
return platforms.value!
}

View File

@ -1,94 +0,0 @@
import type {
ExtractedRouteMethod,
NitroFetchOptions,
NitroFetchRequest,
TypedInternalResponse,
} from "nitropack/types";
import { FetchError } from "ofetch";
interface DropFetch<
DefaultT = unknown,
DefaultR extends NitroFetchRequest = NitroFetchRequest,
> {
<
T = DefaultT,
R extends NitroFetchRequest = DefaultR,
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
>(
request: R,
opts?: O & { failTitle?: string },
): Promise<
// sometimes there is an error, other times there isn't
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
TypedInternalResponse<
R,
T,
NitroFetchOptions<R> extends O ? "get" : ExtractedRouteMethod<R, O>
>
>;
}
export const $dropFetch: DropFetch = async (rawRequest, opts) => {
const requestParts = rawRequest.toString().split("/");
requestParts.forEach((part, index) => {
if (!part.startsWith(":")) {
return;
}
const partName = part.slice(1);
const replacement = opts?.params?.[partName] as string | undefined;
if (!replacement) {
return;
}
requestParts[index] = replacement;
delete opts?.params?.[partName];
});
const request = requestParts.join("/");
// If not in setup
if (!getCurrentInstance()?.proxy) {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Excessive stack depth comparing types
return await $fetch(request, opts);
} catch (e) {
if (import.meta.client && opts?.failTitle) {
console.warn(e);
createModal(
ModalType.Notification,
{
title: opts.failTitle,
description:
(e as FetchError)?.message ?? (e as string).toString(),
//buttonText: $t("common.close"),
},
(_, c) => c(),
);
}
if(e instanceof FetchError) {
e.message = e.data.message ?? e.message;
}
throw e;
}
}
const id = request.toString();
const state = useState(id);
if (state.value) {
// Deep copy
const object = JSON.parse(JSON.stringify(state.value));
// Never use again on client
if (import.meta.client) state.value = undefined;
return object;
}
const headers = useRequestHeaders(["cookie", "authorization"]);
const data = await $fetch(request, {
...opts,
headers: { ...headers, ...opts?.headers },
});
if (import.meta.server) state.value = data;
return data;
};

View File

@ -1,12 +0,0 @@
export type StoreFilterOption = {
name: string;
param: string;
options: Array<StoreSortOption>;
multiple?: boolean;
};
export type StoreSortOption = {
name: string;
param: string;
platformIcon?: { key: string; fallback?: string };
};

View File

@ -1,13 +0,0 @@
import type { UserModel } from "~~/prisma/client/models";
// undefined = haven't check
// null = check, no user
// {} = check, user
export const useUser = () => useState<UserModel | undefined | null>(undefined);
export const updateUser = async () => {
const user = useUser();
if (user.value === null) return;
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
};

View File

@ -1,25 +0,0 @@
import type { SerializeObject } from "nitropack";
import type { UserModel } from "~~/prisma/client/models";
import type { AuthMec } from "~~/prisma/client/enums";
export const useUsers = () =>
useState<
| Array<
SerializeObject<
UserModel & {
authMecs?: Array<{ id: string; mec: AuthMec }>;
}
>
>
| undefined
>("users", () => undefined);
export const fetchUsers = async () => {
const users = useUsers();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore forget why this ignor exists
const newValue: User[] = await $dropFetch("/api/v1/admin/users");
users.value = newValue;
return newValue;
};

View File

@ -1,34 +0,0 @@
<template>
<div v-if="!noWrapper" class="flex flex-col w-full min-h-screen bg-zinc-900">
<LazyUserHeader class="z-50" hydrate-on-idle />
<div class="grow flex">
<NuxtPage />
</div>
<LazyUserFooter class="z-50" hydrate-on-interaction />
</div>
<div v-else class="flex w-full min-h-screen bg-zinc-900">
<NuxtPage />
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const i18nHead = useLocaleHead();
const noWrapper = !!route.query.noWrapper;
const { t } = useI18n();
useHead({
htmlAttrs: {
lang: i18nHead.value.htmlAttrs.lang,
// @ts-expect-error head.value.htmlAttrs.dir is not typed as strictly as it should be
dir: i18nHead.value.htmlAttrs.dir,
},
// // seo headers
// link: [...i18nHead.value.link],
// meta: [...i18nHead.value.meta],
titleTemplate(title) {
return title ? t("titleTemplate", [title]) : t("title");
},
});
</script>

View File

@ -1,149 +0,0 @@
<template>
<div>
<div class="mx-auto max-w-2xl lg:mx-0">
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.devices.title") }}
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.devices.subheader") }}
</p>
</div>
<div
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.devices.platform") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.devices.capabilities") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.devices.lastConnected") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="client in clients"
:key="client.id"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ client.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<span
class="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20"
>
{{ client.platform }}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<div class="flex flex-wrap gap-2">
<span
v-for="capability in client.capabilities"
:key="capability"
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
>
<CheckIcon class="size-3" />
{{ capability }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime :date="client.lastConnected" />
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => revokeClientWrapper(client.id)"
>
{{ $t("account.devices.revoke") }}
<span class="sr-only">
{{ $t("chars.srComma", [client.name]) }}
</span>
</button>
</td>
</tr>
<tr v-if="clients.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
{{ $t("account.devices.noDevices") }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon } from "@heroicons/vue/24/outline";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore pending https://github.com/nitrojs/nitro/issues/2758
const clients = ref(await $dropFetch("/api/v1/user/client"));
const { t } = useI18n();
async function revokeClient(id: string) {
await $dropFetch(`/api/v1/user/client/${id}`, { method: "DELETE" });
}
// clients.value.push({
// id: "example-client",
// userId: "example-user",
// name: "Example Client",
// platform: "Windows",
// capabilities: ["TrackPlaytime"],
// lastConnected: new Date().toISOString(),
// });
function revokeClientWrapper(id: string) {
revokeClient(id)
.then(() => {
const index = clients.value.findIndex((e) => e.id == id);
clients.value.splice(index, 1);
})
.catch((e) => {
createModal(
ModalType.Notification,
{
title: t("errors.revokeClient"),
description: t("errors.revokeClientFull", String(e)),
},
(_, c) => c(),
);
});
}
</script>

View File

@ -1,94 +0,0 @@
<template>
<!-- go away eslint -->
<div />
<!-- I don't want to localize this -->
<!--
<div>
<div v-if="user" class="mx-auto max-w-2xl lg:mx-0">
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
Hello, {{ user.displayName }}!
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
Welcome to your Drop account. Here you can view and manage your account
information.
</p>
</div>
<div v-if="user" class="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
<div
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
>
<div class="p-6">
<h3 class="text-base font-semibold text-zinc-100">
Account Information
</h3>
<dl class="mt-4 space-y-4">
<div class="flex justify-between">
<dt class="text-sm font-medium text-zinc-400">Username</dt>
<dd class="text-sm text-zinc-100">{{ user.username }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-zinc-400">Email</dt>
<dd class="text-sm text-zinc-100">{{ user.email }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-zinc-400">Account Type</dt>
<dd>
<span
:class="[
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
user.admin
? 'bg-blue-400/10 text-blue-400 ring-blue-400/20'
: 'bg-zinc-400/10 text-zinc-400 ring-zinc-400/20',
]"
>
{{ user.admin ? "Administrator" : "Standard User" }}
</span>
</dd>
</div>
</dl>
</div>
</div>
<div
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
>
<div class="p-6">
<h3 class="text-base font-semibold text-zinc-100">Account Actions</h3>
<div class="mt-4 space-y-3">
<button
type="button"
class="w-full inline-flex items-center justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
>
Change Password
</button>
<button
type="button"
class="w-full inline-flex items-center justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
>
Update Email
</button>
</div>
</div>
</div>
</div>
<div v-else class="flex items-center justify-center min-h-[200px]">
<div class="text-zinc-400">Loading account information...</div>
</div>
</div>
-->
</template>
<script setup lang="ts">
definePageMeta({
layout: "default",
});
useHead({
title: "Account",
});
</script>

View File

@ -1,150 +0,0 @@
<template>
<div>
<div class="border-b border-zinc-800 pb-4 w-full">
<div class="flex items-center justify-between w-full">
<h2
class="text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.notifications.notifications") }}
</h2>
<button
:disabled="notifications.length === 0"
class="inline-flex items-center justify-center gap-x-2 rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800 disabled:hover:scale-100 disabled:hover:shadow-none"
@click="markAllAsRead"
>
<CheckIcon class="size-4" />
{{ $t("account.notifications.markAllAsRead") }}
</button>
</div>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.notifications.desc") }}
</p>
</div>
<div class="mt-4 space-y-4">
<div
v-for="notification in notifications"
:key="notification.id"
class="group relative overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
:class="{ 'opacity-75': notification.read }"
>
<div class="p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-base font-semibold text-zinc-100">
{{ notification.title }}
</h3>
<p class="mt-1 text-sm text-zinc-400">
{{ notification.description }}
</p>
<div class="mt-4 flex flex-wrap gap-2">
<NuxtLink
v-for="[name, href] in notification.actions.map((v) =>
v.split('|'),
)"
:key="href"
:href="href"
class="inline-flex items-center rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20 transition-all duration-200 hover:bg-blue-400/20 hover:scale-105 active:scale-95"
>
{{ name }}
</NuxtLink>
</div>
</div>
<div class="ml-4 flex flex-shrink-0 items-center gap-x-2">
<span class="text-xs text-zinc-500">
<RelativeTime :date="notification.created" />
</span>
<button
v-if="!notification.read"
type="button"
class="inline-flex gap-x-1 items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20 transition-all duration-200 hover:bg-zinc-400/20 hover:scale-105 active:scale-95"
@click="markAsRead(notification.id)"
>
<CheckIcon class="size-3" />
{{ $t("account.notifications.markAsRead") }}
</button>
<button
type="button"
class="inline-flex gap-x-1 items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="deleteNotification(notification.id)"
>
<TrashIcon class="size-3" />
{{ $t("delete") }}
</button>
</div>
</div>
</div>
</div>
<div
v-if="notifications.length === 0"
class="rounded-xl border border-zinc-800 bg-zinc-900 p-8 text-center"
>
<p class="text-sm text-zinc-400">
{{ $t("account.notifications.none") }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, TrashIcon } from "@heroicons/vue/24/outline";
import type { NotificationModel } from "~~/prisma/client/models";
import type { SerializeObject } from "nitropack";
definePageMeta({
layout: "default",
});
const { t } = useI18n();
useHead({
title: t("account.notifications.title"),
});
// Fetch notifications
const notifications = ref<SerializeObject<NotificationModel>[]>([]);
async function fetchNotifications() {
const { data } = await useFetch("/api/v1/notifications");
notifications.value = data.value || [];
}
// Initial fetch
await fetchNotifications();
// Mark a notification as read
async function markAsRead(id: string) {
await $dropFetch(`/api/v1/notifications/${id}/read`, { method: "POST" });
const notification = notifications.value.find(
(n: SerializeObject<NotificationModel>) => n.id === id,
);
if (notification) {
notification.read = true;
}
}
// Mark all notifications as read
async function markAllAsRead() {
await $dropFetch("/api/v1/notifications/readall", { method: "POST" });
notifications.value.forEach(
(notification: SerializeObject<NotificationModel>) => {
notification.read = true;
},
);
}
// Delete a notification
async function deleteNotification(id: string) {
await $dropFetch(`/api/v1/notifications/${id}`, { method: "DELETE" });
const index = notifications.value.findIndex(
(n: SerializeObject<NotificationModel>) => n.id === id,
);
if (index !== -1) {
notifications.value.splice(index, 1);
}
}
</script>

View File

@ -1,229 +0,0 @@
<template>
<div>
<div class="w-full flex justify-between items-center">
<div>
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.token.title") }}
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.token.subheader") }}
</p>
</div>
<div>
<LoadingButton :loading="false" @click="() => (createOpen = true)">
{{ $t("common.create") }}
</LoadingButton>
</div>
</div>
<div
v-if="newToken"
class="mt-4 rounded-md p-4 bg-green-500/10 outline outline-green-500/20"
>
<div class="flex">
<div class="shrink-0">
<CheckCircleIcon class="size-5 text-green-400" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-300">
{{ $t("account.token.success") }}
</p>
<p class="text-xs text-green-300/70">
{{ $t("account.token.successNote") }}
</p>
<p
class="mt-2 bg-zinc-950 px-3 py-2 font-mono text-zinc-400 rounded-xl"
>
{{ newToken }}
</p>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button
type="button"
class="inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-hidden dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900"
@click="() => (newToken = undefined)"
>
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<div
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.acls") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.expiry") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="(token, tokenIdx) in tokens"
:key="token.id"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ token.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<div class="flex flex-wrap gap-2">
<span
v-for="acl in token.acls"
:key="acl"
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
>
{{ acl }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime v-if="token.expiresAt" :date="token.expiresAt" />
<span v-else>{{ $t("account.token.noExpiry") }}</span>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => revokeToken(tokenIdx)"
>
{{ $t("account.token.revoke") }}
<span class="sr-only">
{{ $t("chars.srComma", [token.name]) }}
</span>
</button>
</td>
</tr>
<tr v-if="tokens.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
{{ $t("account.token.noTokens") }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<ModalCreateToken
v-model="createOpen"
:acls="acls"
:loading="createLoading"
:suggested-name="suggestedName"
:suggested-acls="suggestedAcls"
@create="(name, acls, expiry) => createToken(name, acls, expiry)"
/>
</div>
</template>
<script setup lang="ts">
import { ArkErrors, type } from "arktype";
import { DateTime, type DurationLike } from "luxon";
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/20/solid";
const tokens = ref(await $dropFetch("/api/v1/user/token"));
const acls = await $dropFetch("/api/v1/user/token/acls");
const createOpen = ref(false);
const createLoading = ref(false);
const newToken = ref<string | undefined>();
const suggestedName = ref();
const suggestedAcls = ref<string[]>([]);
const payloadParser = type({
name: "string?",
acls: "string[]?",
});
const route = useRoute();
if (route.query.payload) {
try {
const rawPayload = JSON.parse(atob(route.query.payload.toString()));
const payload = payloadParser(rawPayload);
if (payload instanceof ArkErrors) throw payload;
suggestedName.value = payload.name;
suggestedAcls.value = payload.acls ?? [];
createOpen.value = true;
} catch {
throw createError({
statusCode: 400,
message: "Failed to parse the token create payload.",
fatal: true,
});
}
}
async function createToken(
name: string,
acls: string[],
expiry: DurationLike | undefined,
) {
createLoading.value = true;
try {
const result = await $dropFetch("/api/v1/user/token", {
method: "POST",
body: {
name,
acls,
expiry: expiry ? DateTime.now().plus(expiry) : undefined,
},
failTitle: "Failed to create API token.",
});
console.log(result);
newToken.value = result.token;
tokens.value.push(result);
} finally {
/* empty */
}
createOpen.value = false;
createLoading.value = false;
}
async function revokeToken(index: number) {
const token = tokens.value[index];
if (!token) return;
await $dropFetch("/api/v1/user/token/:id", {
method: "DELETE",
params: {
id: token.id,
},
failTitle: "Failed to revoke token.",
});
tokens.value.splice(index, 1);
}
</script>

View File

@ -1,11 +0,0 @@
<template><div /></template>
<script setup lang="ts">
definePageMeta({
layout: "admin",
});
useHead({
title: "Home",
});
</script>

View File

@ -1,101 +0,0 @@
<template>
<div
class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900"
>
<div
class="bg-zinc-950 w-full flex flex-col sm:flex-row items-center gap-2 justify-between pr-2"
>
<!--start-->
<div>
<div class="pt-4 inline-flex gap-x-2">
<div
v-for="[value, { icon }] in Object.entries(components)"
:key="value"
>
<button
:class="[
'inline-flex items-center gap-x-1 py-2 px-3 rounded-t-md font-semibold text-sm',
value == currentMode
? 'bg-zinc-900 text-zinc-100'
: 'bg-transparent text-zinc-500',
]"
@click="() => (currentMode = value as GameEditorMode)"
>
<component :is="icon" class="size-4" />
{{ value }}
</button>
</div>
</div>
</div>
<div>
<!-- open in store button -->
<NuxtLink
:href="`/store/${game.id}`"
type="button"
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
{{ $t("library.admin.openStore") }}
<ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true"
/>
</NuxtLink>
</div>
</div>
<component
:is="components[currentMode].editor"
v-model="game"
:unimported-versions="unimportedVersions"
/>
</div>
</template>
<script setup lang="ts">
import { GameEditorMetadata, GameEditorVersion } from "#components";
import {
ArrowTopRightOnSquareIcon,
DocumentIcon,
ServerStackIcon,
} from "@heroicons/vue/24/outline";
import type { Component } from "vue";
const route = useRoute();
const gameId = route.params.id.toString();
const { game: rawGame, unimportedVersions } = await $dropFetch(
`/api/v1/admin/game/:id`,
{ params: { id: gameId } },
);
const game = ref(rawGame);
definePageMeta({
layout: "admin",
});
enum GameEditorMode {
Metadata = "Metadata",
Versions = "Versions",
}
const components: {
[key in GameEditorMode]: { editor: Component; icon: Component };
} = {
[GameEditorMode.Metadata]: { editor: GameEditorMetadata, icon: DocumentIcon },
[GameEditorMode.Versions]: {
editor: GameEditorVersion,
icon: ServerStackIcon,
},
};
const currentMode = ref<GameEditorMode>(GameEditorMode.Metadata);
useHead({
title: `${currentMode.value} - ${game.value.mName}`,
});
watch(currentMode, (v) => {
useHead({
title: `${v} - ${game.value.mName}`,
});
});
</script>

View File

@ -1,357 +0,0 @@
<template>
<div class="flex flex-col gap-y-6 w-full max-w-md">
<Listbox
as="div"
:model="currentlySelectedGame"
@update:model-value="(value) => updateSelectedGame(value)"
>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">
{{ $t("library.admin.import.selectGame") }}
</ListboxLabel>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<span v-if="currentlySelectedGame != -1" class="block truncate"
>{{ games.unimportedGames[currentlySelectedGame].game }}
<span
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
>{{
games.unimportedGames[currentlySelectedGame].library.name
}}</span
></span
>
<span v-else class="block truncate text-zinc-400">{{
$t("library.admin.import.selectDir")
}}</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-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="({ game, library }, gameIdx) in games.unimportedGames"
:key="game"
v-slot="{ active }"
as="template"
:value="gameIdx"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
gameIdx === currentlySelectedGame
? 'font-semibold'
: 'font-normal',
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
]"
>{{ game }}
<span
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
>{{ library.name }}</span
></span
>
<span
v-if="gameIdx === currentlySelectedGame"
: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>
<div
v-if="games.unimportedGames.length == 0"
class="w-full uppercase font-display font-bold text-zinc-600 p-2 text-center"
>
Nothing to import
</div>
</ListboxOptions>
</transition>
</div>
</Listbox>
<Listbox v-model="currentImportMode" as="div">
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">
Import as
</ListboxLabel>
<div class="relative mt-2">
<ListboxButton
class="relative inline-flex items-center w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<span class="inline-flex items-top gap-x-2">
<component
:is="importModes[currentImportMode].icon"
class="text-blue-600 size-8 p-1 bg-zinc-800 rounded-sm mt-1"
/>
<div>
<h1 class="text-sm font-bold text-zinc-200">
{{ importModes[currentImportMode].name }}
</h1>
<p class="text-xs text-zinc-400 max-w-xs">
{{ importModes[currentImportMode].description }}
</p>
</div>
</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-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="[mode, metadata] in Object.entries(importModes)"
:key="mode"
v-slot="{ active }"
as="template"
:value="mode"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
mode == currentImportMode ? 'font-semibold' : 'font-normal',
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
]"
>{{ metadata.name }}
<span
v-if="mode == currentImportMode"
: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>
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div class="flex items-center justify-between gap-x-8">
<span class="flex grow flex-col">
<label
id="bulkImport-label"
class="text-sm/6 font-medium text-zinc-100"
>{{ $t("library.admin.import.bulkImportTitle") }}</label
>
<span id="bulkImport-description" class="text-sm text-zinc-400">{{
$t("library.admin.import.bulkImportDescription")
}}</span>
</span>
<div
class="group relative inline-flex w-11 shrink-0 rounded-full bg-zinc-800 p-0.5 inset-ring inset-ring-zinc-100/5 outline-offset-2 outline-blue-600 transition-colors duration-200 ease-in-out has-checked:bg-blue-600 has-focus-visible:outline-2"
>
<span
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-zinc-100/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5"
/>
<input
id="bulkImport"
v-model="bulkImportMode"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
name="bulkImport"
aria-labelledby="bulkImport-label"
aria-describedby="bulkImport-description"
/>
</div>
</div>
<component
:is="importModes[currentImportMode].component"
v-if="currentlySelectedGame !== -1"
:game-name="games.unimportedGames[currentlySelectedGame].game"
:loading="importLoading"
:error="importError"
@import="(...v: unknown[]) => importModes[currentImportMode].import(...v)"
/>
</div>
</template>
<script setup lang="ts">
import { ImportGame, ImportRedist } from "#components";
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { PuzzlePieceIcon, ArchiveBoxIcon } from "@heroicons/vue/24/solid";
import type { FetchError } from "ofetch";
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
definePageMeta({
layout: "admin",
});
type ImportMode = "Game" | "Redist";
const importModes = shallowRef<{
[key in ImportMode]: {
name: string;
description: string;
icon: Component;
component: Component;
import: (...v: unknown[]) => void;
};
}>({
Game: {
name: "Game",
description: "Games can be added to user libraries, installed, and played.",
icon: PuzzlePieceIcon,
component: ImportGame,
import: importGame_wrapper as (v: unknown) => void,
},
Redist: {
name: "Redistributable",
description:
"Redistributables are packaged dependencies for games, that are installed alongside and required to play certain games.",
icon: ArchiveBoxIcon,
component: ImportRedist,
import: importRedist as (v: unknown, k: unknown) => void,
},
});
const currentImportMode = ref<ImportMode>("Game");
const { t } = useI18n();
const rawGames = await $dropFetch("/api/v1/admin/import/game");
const games = ref(rawGames);
const currentlySelectedGame = ref(-1);
const bulkImportMode = ref(false);
async function updateSelectedGame(value: number) {
if (currentlySelectedGame.value == value || value == -1) return;
const option = games.value.unimportedGames[value];
if (!option) return;
currentlySelectedGame.value = value;
}
const router = useRouter();
const importLoading = ref(false);
const importError = ref<string | undefined>();
async function importGame(metadata: GameMetadataSearchResult | undefined) {
const option = games.value.unimportedGames[currentlySelectedGame.value];
const { taskId } = await $dropFetch("/api/v1/admin/import/game", {
method: "POST",
body: {
path: option.game,
library: option.library.id,
metadata,
},
});
if (!bulkImportMode.value) {
router.push(`/admin/task/${taskId}`);
} else {
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
currentlySelectedGame.value = -1;
}
}
async function importGame_wrapper(
metadata: GameMetadataSearchResult | undefined,
) {
importLoading.value = true;
importError.value = undefined;
try {
await importGame(metadata);
} catch (error) {
console.warn(error);
importError.value =
(error as FetchError)?.message || t("errors.unknown");
}
importLoading.value = false;
}
async function importRedist(data: object, platform: object | undefined) {
importLoading.value = true;
importError.value = undefined;
try {
const option = games.value.unimportedGames[currentlySelectedGame.value];
const formData = new FormData();
for (const [key, value] of Object.entries(data)) {
formData.append(
key,
value,
);
}
if (platform) {
for (const [key, value] of Object.entries(platform)) {
// Because we know there will be no file, and we need to handle more complex objects for
// the platform, we do this.
// Maybe we shouldn't.
formData.append(
`platform.${key}`,
typeof value === "object" ? JSON.stringify(value) : value,
);
}
}
formData.append("library", option.library.id);
formData.append("path", option.game);
const redist = await $dropFetch("/api/v1/admin/import/redist", {
body: formData,
method: "POST",
});
if (!bulkImportMode.value) {
router.push(`/admin/library/r/${redist.id}`);
} else {
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
currentlySelectedGame.value = -1;
}
} catch (error) {
console.warn(error);
importError.value =
(error as FetchError)?.message || t("errors.unknown");
}
importLoading.value = false;
}
</script>

View File

@ -1,496 +0,0 @@
<template>
<div class="space-y-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.gameLibrary") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.subheader") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/library/sources"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.sources.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div v-if="toImport" class="rounded-md bg-blue-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<InformationCircleIcon
class="h-5 w-5 text-blue-400"
aria-hidden="true"
/>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<p class="text-sm text-blue-400">
{{ $t("library.admin.detectedGame") }}
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
href="/admin/library/import"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
<i18n-t
keypath="library.admin.import.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</p>
</div>
</div>
</div>
<div class="mt-2 grid grid-cols-1">
<input
id="search"
v-model="searchQuery"
type="text"
name="search"
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
:placeholder="$t('library.search')"
/>
<MagnifyingGlassIcon
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
aria-hidden="true"
/>
</div>
<div class="flex gap-x-4 text-zinc-300 font-bold uppercase font-display text-sm">
<span class="inline-flex items-center gap-x-1"
><div class="size-2 rounded-full bg-blue-600" />
Game</span
>
<span class="inline-flex items-center gap-x-1"
><div class="size-2 rounded-full bg-emerald-600" />
Redistributable</span
>
</div>
<ul
role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
>
<li
v-for="entry in filteredLibrary"
:key="entry.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
>
<div
v-if="entry.type === 'game'"
class="relative flex flex-1 flex-row p-4 gap-x-4"
>
<div
class="absolute top-0 right-0 w-10 bg-blue-600 h-4 rotate-[45deg] translate-x-1/2"
/>
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(entry.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ entry.mName }}
<button
type="button"
:class="[
'rounded-full p-1 shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2',
entry.featured
? 'bg-yellow-400 hover:bg-yellow-300 focus-visible:outline-yellow-400 text-zinc-900'
: 'bg-zinc-800 hover:bg-zinc-700 focus-visible:outline-zinc-400 text-white',
]"
@click="() => featureGame(entry.id)"
>
<svg
v-if="gameFeatureLoading[entry.id]"
aria-hidden="true"
:class="[
entry.featured ? ' fill-zinc-900' : 'fill-zinc-100',
'size-3 text-transparent animate-spin',
]"
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>
<StarIcon v-else class="size-3" aria-hidden="true" />
</button>
<span
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
>{{ entry.library.name }}</span
>
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ entry.mShortDescription }}
</dd>
<dt class="sr-only">
{{ $t("library.admin.metadataProvider") }}
</dt>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/library/g/${entry.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.openEditor"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteGame(entry.id)"
>
{{ $t("delete") }}
</button>
</div>
</div>
</div>
<div
v-else-if="entry.type === 'redist'"
class="relative flex flex-1 flex-row p-4 gap-x-4"
>
<div
class="absolute top-0 right-0 w-10 bg-emerald-600 h-4 rotate-[45deg] translate-x-1/2"
/>
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(entry.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ entry.mName }}
<span
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
>{{ entry.library.name }}</span
>
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ entry.mShortDescription }}
</dd>
</dl>
<dl
v-if="entry.platform"
class="mt-2 flex items-center text-zinc-200 font-semibold text-sm gap-x-1 p-2 bg-zinc-800 rounded-xl"
>
<IconsPlatform
:platform="entry.platform.id"
:fallback="entry.platform.iconSvg"
class="size-6 text-blue-600"
/>
<span>{{ entry.platform.platformName }}</span>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/library/r/${entry.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.openEditor"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteRedist(entry.id)"
>
{{ $t("delete") }}
</button>
</div>
</div>
</div>
<div v-if="entry.hasNotifications" class="flex flex-col gap-y-2 p-2">
<div
v-if="entry.notifications.toImport"
class="rounded-md bg-blue-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<InformationCircleIcon
class="h-5 w-5 text-blue-400"
aria-hidden="true"
/>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<p class="text-sm text-blue-400">
{{ $t("library.admin.detectedVersion") }}
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
:href="`/admin/library/g/${entry.id}/import`"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
<i18n-t
keypath="library.admin.import.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</p>
</div>
</div>
</div>
<div
v-if="entry.notifications.noVersions"
class="rounded-md bg-yellow-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<ExclamationTriangleIcon
class="h-5 w-5 text-yellow-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-600">
{{ $t("library.admin.version.noVersions") }}
</h3>
</div>
</div>
</div>
<div
v-if="entry.notifications.offline"
class="rounded-md bg-red-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<ExclamationCircleIcon
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">
{{ $t("library.admin.offline") }}
</h3>
</div>
</div>
</div>
</div>
</li>
<p
v-if="filteredLibrary.length == 0 && libraryGames.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
</p>
<p
v-if="
filteredLibrary.length == 0 &&
libraryGames.length == 0 &&
libraryState.hasLibraries
"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.noGames") }}
</p>
<p
v-else-if="!libraryState.hasLibraries"
class="flex flex-col gap-2 text-zinc-600 text-center col-span-4"
>
<span class="text-sm font-display font-bold uppercase">{{
$t("library.admin.libraryHint")
}}</span>
<NuxtLink
class="transition text-xs text-zinc-600 hover:underline hover:text-zinc-400"
href="https://docs.droposs.org/docs/library"
target="_blank"
>
<i18n-t
keypath="library.admin.libraryHintDocsLink"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</p>
</ul>
</div>
</template>
<script setup lang="ts">
import {
ExclamationTriangleIcon,
ExclamationCircleIcon,
} from "@heroicons/vue/16/solid";
import {
ArrowTopRightOnSquareIcon,
InformationCircleIcon,
StarIcon,
} from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
const { t } = useI18n();
definePageMeta({
layout: "admin",
});
useHead({
title: t("library.admin.title"),
});
const searchQuery = ref("");
const libraryState = await $dropFetch("/api/v1/admin/library");
const toImport = ref(
Object.values(libraryState.unimportedGames).flat().length > 0,
);
// Potentially make a server-side transformation to make the client lighter
function clientSideTransformation<T, V extends keyof T, K extends string>(
values: Array<T & { status: (typeof libraryState.games)[number]["status"] }>,
expand: V,
type: K,
): Array<
T[V] & {
status: "online" | "offline";
type: K;
hasNotifications?: boolean;
notifications: {
noVersions?: boolean;
toImport?: boolean;
offline?: boolean;
};
}
> {
return values.map((e) => {
if (e.status == "offline") {
return {
...e[expand],
type: type,
status: "offline" as const,
hasNotifications: true,
notifications: {
offline: true,
},
};
}
const noVersions = e.status.noVersions;
const toImport = e.status.unimportedVersions.length > 0;
return {
...e[expand],
type: type,
notifications: {
noVersions,
toImport,
},
hasNotifications: noVersions || toImport,
status: "online" as const,
};
});
}
const libraryGames = ref(
clientSideTransformation(libraryState.games, "value", "game"),
);
const libraryRedists = ref(
clientSideTransformation(libraryState.redists, "value", "redist"),
);
const filteredLibrary = computed(() =>
[...libraryGames.value, ...libraryRedists.value].filter((e) => {
if (!searchQuery.value) return true;
const searchQueryLower = searchQuery.value.toLowerCase();
if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
if (e.mShortDescription.toLowerCase().includes(searchQueryLower))
return true;
return false;
}),
);
async function deleteGame(id: string) {
await $dropFetch(`/api/v1/admin/game/${id}`, {
method: "DELETE",
failTitle: "Failed to delete game",
});
const index = libraryGames.value.findIndex((e) => e.id === id);
libraryGames.value.splice(index, 1);
toImport.value = true;
}
async function deleteRedist(id: string) {
await $dropFetch(`/api/v1/admin/redist/${id}`, {
method: "DELETE",
failTitle: "Failed to delete game",
});
const index = libraryRedists.value.findIndex((e) => e.id === id);
libraryRedists.value.splice(index, 1);
toImport.value = true;
}
const gameFeatureLoading = ref<{ [key: string]: boolean }>({});
async function featureGame(id: string) {
const gameIndex = libraryGames.value.findIndex((e) => e.id === id);
const game = libraryGames.value[gameIndex];
gameFeatureLoading.value[game.id] = true;
await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
params: {
id: game.id,
},
body: { featured: !game.featured },
failTitle: "Failed to feature/unfeature game",
});
libraryGames.value[gameIndex].featured = !game.featured;
gameFeatureLoading.value[game.id] = false;
}
</script>

View File

@ -1 +0,0 @@
<template></template>

View File

@ -1,85 +0,0 @@
<template>
<div
class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900"
>
<div
class="bg-zinc-950 w-full flex flex-col sm:flex-row items-center gap-2 justify-between pr-2"
>
<!--start-->
<div>
<div class="pt-4 inline-flex gap-x-2">
<div
v-for="[value, { icon }] in Object.entries(components)"
:key="value"
>
<button
:class="[
'inline-flex items-center gap-x-1 py-2 px-3 rounded-t-md font-semibold text-sm',
value == currentMode
? 'bg-zinc-900 text-zinc-100'
: 'bg-transparent text-zinc-500',
]"
@click="() => (currentMode = value as RedistEditorMode)"
>
<component :is="icon" class="size-4" />
{{ value }}
</button>
</div>
</div>
</div>
<div>
<!-- open in store button -->
</div>
</div>
<component
:is="components[currentMode].editor"
v-model="redist"
:unimported-versions="unimportedVersions"
/>
</div>
</template>
<script setup lang="ts">
import { GameEditorVersion, RedistEditorMetadata } from "#components";
import { DocumentIcon, ServerStackIcon } from "@heroicons/vue/24/outline";
import type { Component } from "vue";
const route = useRoute();
const redistId = route.params.id.toString();
const { redist: rawRedist, unimportedVersions } = await $dropFetch(
`/api/v1/admin/redist/:id`,
{ params: { id: redistId } },
);
const redist = ref(rawRedist);
definePageMeta({
layout: "admin",
});
enum RedistEditorMode {
Metadata = "Metadata",
Versions = "Versions",
}
const components: {
[key in RedistEditorMode]: { editor: Component; icon: Component };
} = {
[RedistEditorMode.Metadata]: { editor: RedistEditorMetadata, icon: DocumentIcon },
[RedistEditorMode.Versions]: {
editor: GameEditorVersion,
icon: ServerStackIcon,
},
};
const currentMode = ref<RedistEditorMode>(RedistEditorMode.Metadata);
useHead({
title: `${currentMode.value} - ${redist.value.mName}`,
});
watch(currentMode, (v) => {
useHead({
title: `${v} - ${redist.value.mName}`,
});
});
</script>

View File

@ -1,465 +0,0 @@
<template>
<div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.sources.sources") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.sources.desc") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center 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"
@click="() => (actionSourceOpen = true)"
>
{{ $t("common.create") }}
</button>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-zinc-700">
<thead>
<tr>
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("type") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("library.admin.sources.working") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("options") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
<span class="sr-only">{{ $t("common.edit") }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(source, sourceIdx) in sources"
:key="source.id"
class="even:bg-zinc-800"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ source.name }}
</td>
<td
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center"
>
<component
:is="optionsMetadata[source.backend].icon"
class="size-5 text-zinc-400"
/>
{{ optionsMetadata[source.backend].title }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<CheckIcon
v-if="source.working"
class="size-5 text-green-500"
/>
<XMarkIcon v-else class="size-5 text-red-500" />
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.options }}
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-3 text-right text-sm font-medium space-x-2"
>
<button
class="text-blue-500 hover:text-blue-400"
@click="() => edit(sourceIdx)"
>
{{ $t("common.edit") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
</button>
<button
class="text-red-500 hover:text-red-400"
@click="() => deleteSource(sourceIdx)"
>
{{ $t("delete") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<ModalTemplate v-model="actionSourceOpen">
<template #default>
<div>
<DialogTitle
v-if="createMode"
as="h3"
class="text-lg font-medium leading-6 text-white"
>
{{ $t("library.admin.sources.create") }}
</DialogTitle>
<DialogTitle
v-else
as="h3"
class="text-lg font-medium leading-6 text-white"
>
{{ $t("library.admin.sources.edit") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.sources.createDesc") }}
</p>
</div>
<form
class="mt-2 space-y-4"
@submit.prevent="() => performActionSource_wrapper()"
>
<div>
<label
for="name"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("common.name") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.nameDesc") }}
</p>
<div class="mt-2">
<input
id="name"
v-model="sourceName"
name="name"
type="text"
autocomplete="name"
:placeholder="$t('library.admin.sources.namePlaceholder')"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div v-if="createMode">
<label class="block text-sm font-medium leading-6 text-zinc-100">{{
$t("type")
}}</label>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.typeDesc") }}
</p>
<RadioGroup v-model="currentSourceOption" class="mt-2">
<RadioGroupLabel class="sr-only">{{
$t("type")
}}</RadioGroupLabel>
<div class="space-y-4">
<RadioGroupOption
v-for="[source, metadata] in optionsMetadataIter"
:key="source"
v-slot="{ checked }"
as="template"
:value="source"
>
<div
:class="[
'relative block cursor-pointer bg-zinc-800 rounded-lg border border-zinc-900 px-2 py-2 shadow-sm focus:outline-none sm:flex sm:justify-between',
]"
>
<span class="flex items-center gap-x-4">
<div>
<component
:is="metadata.icon"
class="size-12 bg-zinc-900 rounded-xl p-3 text-zinc-400"
/>
</div>
<span class="flex flex-col text-sm">
<RadioGroupLabel
as="span"
class="font-semibold text-zinc-100"
>{{ metadata.title }}
<span class="ml-2 font-mono text-zinc-500 text-xs">{{
source
}}</span>
</RadioGroupLabel>
<RadioGroupDescription
as="span"
class="text-zinc-400 text-xs"
>
{{ metadata.description }}
</RadioGroupDescription>
<NuxtLink
:href="metadata.docsLink"
:external="true"
target="_blank"
class="mt-2 block w-fit rounded-md bg-blue-600 px-2 py-1 text-center text-xs 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"
>
<i18n-t
keypath="library.admin.sources.documentationLink"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</span>
</span>
<span
:class="[
checked ? 'ring-2 ring-blue-600' : '',
'pointer-events-none absolute -inset-px rounded-lg',
]"
aria-hidden="true"
/>
</div>
</RadioGroupOption>
</div>
</RadioGroup>
</div>
<div class="h-[1px] w-full bg-zinc-700 rounded-full" />
<component
:is="optionUIs[currentSourceOption]"
v-model="sourceConfig"
/>
<input type="submit" class="hidden" />
</form>
<div v-if="modalError" 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">
{{ modalError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="modalLoading"
:disabled="modalLoading"
class="w-full sm:w-fit"
@click="() => performActionSource_wrapper()"
>
{{ createMode ? $t("common.create") : $t("common.save") }}
</LoadingButton>
<button
ref="cancelButtonRef"
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="
() => {
editIndex = undefined;
close();
}
"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</div>
</template>
<script setup lang="ts">
/**
* I did something a little cursed for this
* To avoid making a separate modal for saving, we
* instead set the index of the source we want to edit
* and there's a bunch of checks everywhere to switch
* between 'create' and 'edit'
*/
import {
DropLogo,
SourceOptionsFilesystem,
SourceOptionsFlatFilesystem,
} from "#components";
import {
DialogTitle,
RadioGroup,
RadioGroupDescription,
RadioGroupLabel,
RadioGroupOption,
} from "@headlessui/vue";
import {
XCircleIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/vue/20/solid";
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { FetchError } from "ofetch";
import type { Component } from "vue";
import type { LibraryBackend } from "~~/prisma/client/enums";
import type { WorkingLibrarySource } from "~~/server/api/v1/admin/library/sources/index.get";
definePageMeta({
layout: "admin",
});
useHead({
title: "Library Sources",
});
const { t } = useI18n();
// Optional token for setup wizard
const { token = undefined } = defineProps<{ token?: string }>();
const headers = token ? { Authorization: token } : undefined;
const sources = ref(
await $dropFetch<WorkingLibrarySource[]>("/api/v1/admin/library/sources", {
headers,
}),
);
const editIndex = ref<undefined | number>(undefined);
const createMode = computed(() => editIndex.value === undefined);
const actionSourceOpen = ref(false);
const currentSourceOption = ref<LibraryBackend>("Filesystem");
const sourceName = ref("");
const sourceConfig = ref<object>({});
const modalError = ref<undefined | string>();
const modalLoading = ref(false);
const optionUIs: { [key in LibraryBackend]: Component } = {
Filesystem: SourceOptionsFilesystem,
FlatFilesystem: SourceOptionsFlatFilesystem,
};
const optionsMetadata: {
[key in LibraryBackend]: {
title: string;
description: string;
docsLink: string;
icon: Component;
};
} = {
Filesystem: {
title: t("library.admin.sources.fsTitle"),
description: t("library.admin.sources.fsDesc"),
docsLink: "https://docs.droposs.org/docs/library#drop-style",
icon: DropLogo,
},
FlatFilesystem: {
title: t("library.admin.sources.fsFlatTitle"),
description: t("library.admin.sources.fsFlatDesc"),
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
icon: BackwardIcon,
},
};
const optionsMetadataIter = Object.entries(optionsMetadata);
async function performActionSource() {
const createMode = editIndex.value === undefined;
const source = await $dropFetch<WorkingLibrarySource>(
"/api/v1/admin/library/sources",
{
body: {
id: createMode ? undefined : sources.value[editIndex.value!].id,
name: sourceName.value,
backend: createMode ? currentSourceOption.value : undefined,
options: sourceConfig.value,
},
method: createMode ? "POST" : "PATCH",
headers,
},
);
if (createMode) {
sources.value.push(source);
} else {
sources.value[editIndex.value!] = source;
}
}
function performActionSource_wrapper() {
modalError.value = undefined;
modalLoading.value = true;
performActionSource()
.then(
() => {
actionSourceOpen.value = false;
sourceConfig.value = {};
sourceName.value = "";
},
(e) => {
if (e instanceof FetchError) {
console.log(e.data.message);
modalError.value = e.message;
} else {
modalError.value = e as string;
}
},
)
.finally(() => {
modalLoading.value = false;
});
}
function edit(index: number) {
const source = sources.value[index];
if (!source) return;
sourceName.value = source.name;
sourceConfig.value = source.options! as object;
editIndex.value = index;
actionSourceOpen.value = true;
}
async function deleteSource(index: number) {
const source = sources.value[index];
if (!source) return;
try {
await $dropFetch("/api/v1/admin/library/sources", {
method: "DELETE",
body: { id: source.id },
headers,
});
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.library.source.delete.title"),
description: t("errors.library.source.delete.desc", [
// @ts-expect-error attempt to display message on error
e?.message ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
}
sources.value.splice(index, 1);
}
</script>

View File

@ -1,422 +0,0 @@
<template>
<div class="space-y-4">
<div
class="relative overflow-hidden rounded-lg flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2 p-8"
>
<img
:src="useObject(company.mBannerObjectId)"
class="absolute inset-0 w-full h-full object-cover object-center"
/>
<div class="absolute inset-0 bg-zinc-900/80" />
<div class="relative inline-flex items-center gap-4">
<!-- icon image -->
<div class="relative group/iconupload rounded-xl overflow-hidden">
<img :src="useObject(company.mLogoObjectId)" class="size-20" />
<button
class="rounded-xl transition duration-200 absolute inset-0 opacity-0 group-hover/iconupload:opacity-100 focus-visible/iconupload:opacity-100 cursor-pointer bg-zinc-900/80 text-zinc-100 flex flex-col items-center justify-center text-center text-xs font-semibold ring-1 ring-inset ring-zinc-800 px-2"
@click="() => (uploadLogoOpen = true)"
>
<ArrowUpTrayIcon class="size-5" />
<span>{{
$t("library.admin.metadata.companies.editor.uploadIcon")
}}</span>
</button>
</div>
<div class="flex flex-col">
<h1
class="group/name inline-flex items-center gap-x-3 text-5xl font-bold font-display text-zinc-100"
>
{{ company.mName }}
<button @click="() => editName()">
<PencilIcon
class="transition duration-200 xl:opacity-0 group-hover/name:opacity-100 size-8"
/>
</button>
</h1>
<p
class="group/description mt-1 inline-flex items-center gap-x-3 text-lg text-zinc-400 max-w-xl"
>
{{
company.mShortDescription ||
$t("library.admin.metadata.companies.editor.noDescription")
}}
<button @click="() => editShortDescription()">
<PencilIcon
class="transition duration-200 xl:opacity-0 group-hover/description:opacity-100 size-5"
/>
</button>
</p>
<p
class="group/website mt-1 text-zinc-500 inline-flex items-center gap-x-3"
>
{{
company.mWebsite ||
$t("library.admin.metadata.companies.editor.websitePlaceholder")
}}
<button @click="() => editWebsite()">
<PencilIcon
class="transition duration-200 xl:opacity-0 group-hover/website:opacity-100 size-4"
/>
</button>
</p>
</div>
</div>
<button
type="button"
class="relative inline-flex gap-x-3 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"
@click="() => (uploadBannerOpen = true)"
>
{{ $t("library.admin.metadata.companies.editor.uploadBanner") }}
<ArrowUpTrayIcon class="size-4" />
</button>
</div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.companies.editor.libraryTitle") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.companies.editor.libraryDescription") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (addGameModelOpen = true)"
>
<i18n-t
keypath="library.admin.metadata.companies.editor.action"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #plus>
<PlusIcon class="size-4" />
</template>
</i18n-t>
</button>
</div>
</div>
<div class="mt-2 grid grid-cols-1">
<input
id="search"
v-model="searchQuery"
type="text"
name="search"
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
:placeholder="$t('library.admin.metadata.companies.searchGames')"
/>
<MagnifyingGlassIcon
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
aria-hidden="true"
/>
</div>
<ul
role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
>
<li
v-for="game in filteredGames"
:key="game.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border border-zinc-800 group"
>
<div class="flex flex-1 flex-row p-4 gap-x-4">
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(game.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ game.mName }}
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ game.mShortDescription }}
</dd>
</dl>
<div class="mt-4 flex flex-col gap-y-3">
<div class="flex items-center gap-3">
<div
class="group/published relative inline-flex w-7 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/published:translate-x-3"
/>
<input
id="published"
v-model="published[game.id]"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="published-label"
/>
</div>
<label
id="published-label"
for="published"
class="font-medium text-xs text-zinc-100"
>{{
$t("library.admin.metadata.companies.editor.published")
}}</label
>
</div>
<div class="flex items-center gap-3">
<div
class="group/developed relative inline-flex w-7 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/developed:translate-x-3"
/>
<input
id="developed"
v-model="developed[game.id]"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="developed-label"
/>
</div>
<label
id="developed-label"
for="published"
class="font-medium text-xs text-zinc-100"
>{{
$t("library.admin.metadata.companies.editor.developed")
}}</label
>
</div>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => removeGame(game.id)"
>
{{ $t("common.remove") }}
</button>
</div>
</div>
</div>
</li>
<p
v-if="filteredGames.length == 0 && games.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
</p>
<p
v-if="filteredGames.length == 0 && games.length == 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.metadata.companies.noGames") }}
</p>
</ul>
<ModalAddCompanyGame
v-model="addGameModelOpen"
:exclude="games.map((e) => e.id)"
:company-id="company.id"
@created="appendGame"
/>
<ModalUploadFile
v-model="uploadLogoOpen"
:endpoint="`/api/v1/admin/company/${company.id}/icon`"
accept="image/*"
@upload="updateLogo"
/>
<ModalUploadFile
v-model="uploadBannerOpen"
:endpoint="`/api/v1/admin/company/${company.id}/banner`"
accept="image/*"
@upload="updateBanner"
/>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import { ArrowUpTrayIcon, PencilIcon, PlusIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { GameModel } from "~~/prisma/client/models";
definePageMeta({
layout: "admin",
});
const route = useRoute();
const companyId = route.params.id!.toString();
const result = await $dropFetch("/api/v1/admin/company/:id", {
params: { id: companyId },
});
const company = ref(result.company);
const games = ref(result.games);
const addGameModelOpen = ref(false);
const uploadLogoOpen = ref(false);
const uploadBannerOpen = ref(false);
const { t } = useI18n();
useHead({
title: `${company.value.mName} - Company`,
});
const searchQuery = ref("");
const filteredGames = computed(() =>
games.value.filter(
(e: SerializeObject<GameModel>) =>
e.mName.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
e.mShortDescription.includes(searchQuery.value.toLowerCase()),
),
);
function buildToggleProxy(param: "developed" | "published") {
async function tick(id: string, enabled: boolean) {
if (
company.value.developed.length == 0 &&
company.value.published.length == 0
)
return await removeGame(id);
await $dropFetch("/api/v1/admin/company/:id/game", {
method: "PATCH",
params: {
id: company.value.id,
},
body: {
action: param,
enabled,
id,
},
failTitle: `Failed to update ${param} for game`,
});
}
return new Proxy({} as { [key: string]: boolean }, {
get(_target, prop, _reciever) {
return company.value[param].includes(prop.toString());
},
set(_target, prop, value) {
if (typeof value !== "boolean") return false;
const id = prop.toString();
const exists = company.value[param].findIndex((e) => e === id);
if (value && exists == -1) {
company.value[param].push(id);
}
if (!value && exists != -1) {
company.value[param].splice(exists, 1);
}
tick(id, value);
return true;
},
});
}
const published = buildToggleProxy("published");
const developed = buildToggleProxy("developed");
async function removeGame(gameId: string) {
await $dropFetch("/api/v1/admin/company/:id/game", {
params: {
id: company.value.id,
},
body: {
id: gameId,
},
method: "DELETE",
failTitle: "Failed to remove game",
});
const gameIndex = games.value.findIndex((e) => e.id == gameId);
if (gameIndex == -1) return;
games.value.splice(gameIndex, 1);
const publishedIndex = company.value.published.findIndex((e) => e === gameId);
if (publishedIndex != -1) {
company.value.published.splice(publishedIndex, 1);
}
const developedIndex = company.value.developed.findIndex((e) => e === gameId);
if (developedIndex != -1) {
company.value.developed.splice(developedIndex, 1);
}
}
function appendGame(
game: (typeof games.value)[number],
published: boolean,
developed: boolean,
) {
games.value.push(game);
if (published) {
company.value.published.push(game.id);
}
if (developed) {
company.value.developed.push(game.id);
}
}
function buildFieldEditModal(
field: "mName" | "mShortDescription" | "mWebsite",
title: string,
description: string,
) {
function modal() {
createModal(
ModalType.TextInput,
{
title,
description,
dft: company.value[field],
},
async (e, c, s) => {
switch (e) {
case "cancel": {
c();
break;
}
case "submit": {
const result = await $dropFetch("/api/v1/admin/company/:id", {
method: "PATCH",
params: { id: company.value.id },
body: { [field]: s! },
failTitle: "Failed to update company details",
});
company.value[field] = result[field];
c();
break;
}
}
},
);
}
return modal;
}
const editName = buildFieldEditModal(
"mName",
t("library.admin.metadata.companies.modals.nameTitle"),
t("library.admin.metadata.companies.modals.nameDescription"),
);
const editShortDescription = buildFieldEditModal(
"mShortDescription",
t("library.admin.metadata.companies.modals.shortDeckTitle"),
t("library.admin.metadata.companies.modals.shortDeckDescription"),
);
const editWebsite = buildFieldEditModal(
"mWebsite",
t("library.admin.metadata.companies.modals.websiteTitle"),
t("library.admin.metadata.companies.modals.websiteDescription"),
);
function updateLogo(response: { id: string }) {
company.value.mLogoObjectId = response.id;
}
function updateBanner(response: { id: string }) {
company.value.mBannerObjectId = response.id;
}
</script>

View File

@ -1,153 +0,0 @@
<template>
<div class="space-y-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.companies.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.companies.description") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (createCompanyOpen = true)"
>
{{ $t("common.create") }}
</button>
</div>
</div>
<div class="mt-2 grid grid-cols-1">
<input
id="search"
v-model="searchQuery"
type="text"
name="search"
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
:placeholder="$t('library.admin.metadata.companies.search')"
/>
<MagnifyingGlassIcon
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
aria-hidden="true"
/>
</div>
<ul
role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
>
<li
v-for="company in filteredCompanies"
:key="company.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
>
<div class="flex flex-1 flex-row p-4 gap-x-4">
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(company.mLogoObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ company.mName }}
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ company.mShortDescription }}
</dd>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/metadata/companies/${company.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.openEditor"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteCompany(company.id)"
>
{{ $t("delete") }}
</button>
</div>
</div>
</div>
</li>
<p
v-if="filteredCompanies.length == 0 && companies.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
</p>
<p
v-if="filteredCompanies.length == 0 && companies.length == 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.metadata.companies.noCompanies") }}
</p>
</ul>
<ModalCreateCompany
v-model="createCompanyOpen"
@created="(company) => createCompany(company)"
/>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import type { CompanyModel } from "~~/prisma/client/models";
const { t } = useI18n();
definePageMeta({
layout: "admin",
});
useHead({
title: t("library.admin.metadata.companies.title"),
});
const createCompanyOpen = ref(false);
const searchQuery = ref("");
const rawCompanies = await $dropFetch("/api/v1/admin/company");
const companies = ref(rawCompanies);
const filteredCompanies = computed(() =>
companies.value.filter((e: CompanyModel) => {
if (!searchQuery.value) return true;
const searchQueryLower = searchQuery.value.toLowerCase();
if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
if (e.mShortDescription.toLowerCase().includes(searchQueryLower))
return true;
return false;
}),
);
async function deleteCompany(id: string) {
await $dropFetch(`/api/v1/admin/company/:id`, {
method: "DELETE",
params: { id },
failTitle: "Failed to delete company",
});
const index = companies.value.findIndex((e) => e.id === id);
companies.value.splice(index, 1);
}
function createCompany(company: (typeof companies.value)[number]) {
companies.value.push(company);
}
</script>

View File

@ -1,62 +0,0 @@
<template>
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-16">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.tags.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.tags.description") }}
</p>
</div>
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/metadata/tags"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.metadata.tags.action"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.companies.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.companies.description") }}
</p>
</div>
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/metadata/companies"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.metadata.companies.action"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: "admin",
});
</script>

View File

@ -1,75 +0,0 @@
<template>
<div class="space-y-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.tags.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.tags.description") }}
</p>
</div>
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (createModalOpen = true)"
>
{{ $t("library.admin.metadata.tags.create") }}
</button>
</div>
</div>
<div class="flex flex-wrap gap-3">
<div
v-for="(tag, tagIdx) in tags"
:key="tag.id"
class="py-2 px-3 inline-flex gap-x-3 bg-zinc-950 ring-1 ring-zinc-800 text-zinc-300"
>
{{ tag.name }}
<button @click="() => deleteTag(tagIdx)">
<TrashIcon
class="transition size-4 text-zinc-700 hover:text-red-500"
/>
</button>
</div>
</div>
<ModalCreateTag v-model="createModalOpen" @created="onTagCreate" />
</div>
</template>
<script setup lang="ts">
import { TrashIcon } from "@heroicons/vue/24/outline";
import type { SerializeObject } from "nitropack";
import type { GameTagModel } from "~~/prisma/client/models";
definePageMeta({
layout: "admin",
});
const createModalOpen = ref(false);
const tags = ref(
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/admin/tags"),
);
function sort() {
tags.value.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
);
}
sort();
async function onTagCreate(tag: GameTagModel) {
tags.value.push(tag);
sort();
}
async function deleteTag(tagIdx: number) {
const tag = tags.value[tagIdx];
await $dropFetch(`/api/v1/admin/tags/${tag.id}`, {
method: "DELETE",
failTitle: "Failed to delete tag",
});
tags.value.splice(tagIdx, 1);
}
</script>

View File

@ -1,68 +0,0 @@
<template>
<div class="flex flex-col">
<!-- tabs-->
<div>
<div class="border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex gap-x-2" aria-label="Tabs">
<NuxtLink
v-for="(tab, tabIdx) in navigation"
:key="tab.route"
:href="tab.route"
:class="[
currentNavigationIndex == tabIdx
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium',
]"
:aria-current="tab ? 'page' : undefined"
>
<component
:is="tab.icon"
:class="[
currentNavigationIndex == tabIdx
? 'text-blue-500 dark:text-blue-400'
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400',
'mr-2 -ml-0.5 size-5',
]"
aria-hidden="true"
/>
<span>{{ tab.label }}</span>
</NuxtLink>
</nav>
</div>
</div>
<!-- content -->
<div class="mt-4 grow flex">
<NuxtPage />
</div>
</div>
</template>
<script setup lang="ts">
import {
BuildingStorefrontIcon,
CodeBracketIcon,
} from "@heroicons/vue/24/outline";
const navigation: Array<NavigationItem & { icon: Component }> = [
{
label: $t("header.admin.settings.store"),
route: "/admin/settings",
prefix: "/admin/settings",
icon: BuildingStorefrontIcon,
},
{
label: $t("header.admin.settings.tokens"),
route: "/admin/settings/tokens",
prefix: "/admin/settings/tokens",
icon: CodeBracketIcon,
},
];
// const notifications = useNotifications();
// const unreadNotifications = computed(() =>
// notifications.value.filter((e) => !e.read)
// );
const currentNavigationIndex = useCurrentNavigationIndex(navigation);
</script>

View File

@ -1,108 +0,0 @@
<template>
<form class="space-y-4" @submit.prevent="() => saveSettings()">
<div class="pb-4 border-b border-zinc-700">
<h2 class="text-xl font-semibold text-zinc-100">
{{ $t("settings.admin.store.title") }}
</h2>
<h3 class="text-base font-medium text-zinc-400 mb-3 m-x-0">
{{ $t("settings.admin.store.showGamePanelTextDecoration") }}
</h3>
<ul class="flex gap-3">
<li class="inline-block">
<OptionWrapper
:active="showGamePanelTextDecoration"
@click="setShowTitleDescription(true)"
>
<div class="flex">
<GamePanel
:animate="false"
:game="game"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
<li class="inline-block">
<OptionWrapper
:active="!showGamePanelTextDecoration"
@click="setShowTitleDescription(false)"
>
<div class="flex">
<GamePanel
:game="game"
:show-title-description="false"
:animate="false"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
</ul>
</div>
<LoadingButton
type="submit"
class="inline-flex w-full shadow-sm sm:w-auto"
:loading="saving"
:disabled="!allowSave"
>
{{ allowSave ? $t("common.save") : $t("common.saved") }}
</LoadingButton>
</form>
</template>
<script setup lang="ts">
import { FetchError } from "ofetch";
const { t } = useI18n();
definePageMeta({
layout: "admin",
});
useHead({
title: t("settings.admin.title"),
});
const settings = await $dropFetch("/api/v1/settings");
const { game } = await $dropFetch("/api/v1/admin/settings/dummy-data");
const allowSave = ref(false);
const showGamePanelTextDecoration = ref<boolean>(
settings.showGamePanelTextDecoration,
);
function setShowTitleDescription(value: boolean) {
showGamePanelTextDecoration.value = value;
allowSave.value = true;
}
const saving = ref<boolean>(false);
async function saveSettings() {
saving.value = true;
try {
await $dropFetch("/api/v1/admin/settings", {
method: "PATCH",
body: {
showGamePanelTextDecoration: showGamePanelTextDecoration.value,
},
});
} catch (e) {
createModal(
ModalType.Notification,
{
title: `Failed to save settings.`,
description:
e instanceof FetchError
? (e.message)
: (e as string).toString(),
},
(_, c) => c(),
);
}
saving.value = false;
allowSave.value = false;
}
</script>

View File

@ -1,233 +0,0 @@
<template>
<div class="w-full">
<div class="w-full flex justify-between items-center">
<div>
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.token.title") }}
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.token.subheader") }}
</p>
</div>
<div>
<LoadingButton :loading="false" @click="() => (createOpen = true)">
{{ $t("common.create") }}
</LoadingButton>
</div>
</div>
<div
v-if="newToken"
class="mt-4 rounded-md p-4 bg-green-500/10 outline outline-green-500/20"
>
<div class="flex">
<div class="shrink-0">
<CheckCircleIcon class="size-5 text-green-400" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-300">
{{ $t("account.token.success") }}
</p>
<p class="text-xs text-green-300/70">
{{ $t("account.token.successNote") }}
</p>
<p
class="mt-2 bg-zinc-950 px-3 py-2 font-mono text-zinc-400 rounded-xl"
>
{{ newToken }}
</p>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button
type="button"
class="inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-hidden dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900"
@click="() => (newToken = undefined)"
>
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<div
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.acls") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.expiry") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="(token, tokenIdx) in tokens"
:key="token.id"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ token.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<div class="flex flex-wrap gap-2">
<span
v-for="acl in token.acls"
:key="acl"
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
>
{{ acl }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime v-if="token.expiresAt" :date="token.expiresAt" />
<span v-else>{{ $t("account.token.noExpiry") }}</span>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => revokeToken(tokenIdx)"
>
{{ $t("account.token.revoke") }}
<span class="sr-only">
{{ $t("chars.srComma", [token.name]) }}
</span>
</button>
</td>
</tr>
<tr v-if="tokens.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
{{ $t("account.token.noTokens") }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<ModalCreateToken
v-model="createOpen"
:acls="acls"
:loading="createLoading"
:suggested-name="suggestedName"
:suggested-acls="suggestedAcls"
@create="(name, acls, expiry) => createToken(name, acls, expiry)"
/>
</div>
</template>
<script setup lang="ts">
import { ArkErrors, type } from "arktype";
import { DateTime, type DurationLike } from "luxon";
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/20/solid";
definePageMeta({
layout: "admin",
});
const tokens = ref(await $dropFetch("/api/v1/admin/token"));
const acls = await $dropFetch("/api/v1/admin/token/acls");
const createOpen = ref(false);
const createLoading = ref(false);
const newToken = ref<string | undefined>();
const suggestedName = ref();
const suggestedAcls = ref<string[]>([]);
const payloadParser = type({
name: "string?",
acls: "string[]?",
});
const route = useRoute();
if (route.query.payload) {
try {
const rawPayload = JSON.parse(atob(route.query.payload.toString()));
const payload = payloadParser(rawPayload);
if (payload instanceof ArkErrors) throw payload;
suggestedName.value = payload.name;
suggestedAcls.value = payload.acls ?? [];
createOpen.value = true;
} catch {
throw createError({
statusCode: 400,
message: "Failed to parse the token create payload.",
fatal: true,
});
}
}
async function createToken(
name: string,
acls: string[],
expiry: DurationLike | undefined,
) {
createLoading.value = true;
try {
const result = await $dropFetch("/api/v1/admin/token", {
method: "POST",
body: {
name,
acls,
expiry: expiry ? DateTime.now().plus(expiry) : undefined,
},
failTitle: "Failed to create API token.",
});
console.log(result);
newToken.value = result.token;
tokens.value.push(result);
} catch {
/* empty */
}
createOpen.value = false;
createLoading.value = false;
}
async function revokeToken(index: number) {
const token = tokens.value[index];
if (!token) return;
await $dropFetch("/api/v1/admin/token/:id", {
method: "DELETE",
params: {
id: token.id,
},
failTitle: "Failed to revoke token.",
});
tokens.value.splice(index, 1);
}
</script>

View File

@ -1,107 +0,0 @@
<template>
<div>
<NuxtLink
to="/admin/task"
class="mb-2 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"
>
<i18n-t keypath="tasks.admin.back" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
</template>
</i18n-t>
</NuxtLink>
<div
v-if="task && task.error"
class="grow w-full flex items-center justify-center"
>
<div class="flex flex-col items-center">
<ExclamationCircleIcon
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"
>
{{ task.error.title }}
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-md">
{{ task.error.description }}
</p>
</div>
</div>
</div>
</div>
<div v-else-if="task" class="flex flex-col w-full gap-y-4">
<h1
class="inline-flex items-center gap-x-3 text-3xl text-zinc-100 font-bold font-display"
>
<div>
<CheckCircleIcon v-if="task.success" class="size-5 text-green-600" />
<div v-else class="size-4 bg-blue-600 rounded-full animate-pulse" />
</div>
{{ task.name }}
</h1>
<div
class="bg-zinc-950 p-2 rounded-md h-[80vh] flex flex-col flex-col-reverse overflow-y-scroll gap-y-1"
>
<LogLine
v-for="(_, idx) in task.log"
:key="idx"
:log="parseTaskLog(task.log.at(-(idx + 1)))"
/>
</div>
<div class="relative h-5 rounded-xl bg-zinc-950 overflow-hidden">
<div
:style="{ width: `${task.progress}%` }"
class="transition-all bg-blue-600 h-full"
/>
<span
class="absolute inset-0 flex items-center justify-center text-blue-200 text-sm font-bold font-display"
>{{
$t("tasks.admin.progress", [Math.round(task.progress * 10) / 10])
}}</span
>
</div>
</div>
<div v-else role="status" class="w-full flex items-center justify-center">
<svg
aria-hidden="true"
class="size-8 text-transparent animate-spin fill-white"
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">{{ $t("common.srLoading") }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon } from "@heroicons/vue/16/solid";
import { ExclamationCircleIcon } from "@heroicons/vue/24/solid";
const route = useRoute();
const taskId = route.params.id.toString();
const task = useTask(taskId);
definePageMeta({
layout: "admin",
});
useHead({
title: "Task",
});
</script>

View File

@ -1,181 +0,0 @@
<template>
<div>
<div>
<h2 class="text-sm font-medium text-zinc-400">
{{ $t("tasks.admin.runningTasksTitle") }}
</h2>
<ul
role="list"
class="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4"
>
<li
v-for="task in liveRunningTasks"
:key="task.value?.id"
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
>
<TaskWidget :task="task.value" :active="true" />
</li>
</ul>
<div
v-if="liveRunningTasks.length == 0"
class="text-zinc-500 text-sm font-semibold"
>
{{ $t("tasks.admin.noTasksRunning") }}
</div>
</div>
<div class="mt-6 w-full grid lg:grid-cols-2 gap-8">
<div>
<h2 class="text-sm font-medium text-zinc-400">
{{ $t("tasks.admin.completedTasksTitle") }}
</h2>
<ul role="list" class="mt-4 grid grid-cols-1 gap-6 lg:grid-cols-2">
<li
v-for="task in historicalTasks"
:key="task.id"
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
>
<TaskWidget :task="task" />
</li>
</ul>
</div>
<div>
<h2 class="text-sm font-medium text-zinc-400">
{{ $t("tasks.admin.dailyScheduledTitle") }}
</h2>
<ul role="list" class="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-6">
<li
v-for="task in dailyTasks"
:key="task"
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
>
<div class="flex w-full items-center justify-between space-x-6 p-6">
<div class="flex-1">
<div class="flex items-center space-x-2">
<h3 class="text-sm font-medium text-zinc-100">
{{ scheduledTasks[task].name }}
</h3>
</div>
<p class="mt-1 text-sm text-zinc-400">
{{ scheduledTasks[task].description }}
</p>
<button
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
@click="() => startTask(task)"
>
<i18n-t
keypath="tasks.admin.execute"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<PlayIcon class="size-4" aria-hidden="true" />
</template>
</i18n-t>
</button>
</div>
</div>
</li>
</ul>
<h2 class="text-sm font-medium text-zinc-400 mt-8">
{{ $t("tasks.admin.weeklyScheduledTitle") }}
</h2>
<ul role="list" class="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-6">
<li
v-for="task in weeklyTasks"
:key="task"
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
>
<div class="flex w-full items-center justify-between space-x-6 p-6">
<div class="flex-1">
<div class="flex items-center space-x-2">
<h3 class="text-sm font-medium text-zinc-100">
{{ scheduledTasks[task].name }}
</h3>
</div>
<p class="mt-1 text-sm text-zinc-400">
{{ scheduledTasks[task].description }}
</p>
<button
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
@click="() => startTask(task)"
>
<i18n-t
keypath="tasks.admin.execute"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<PlayIcon class="size-4" aria-hidden="true" />
</template>
</i18n-t>
</button>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { PlayIcon } from "@heroicons/vue/24/outline";
import type { TaskGroup } from "~~/server/internal/tasks/group";
useHead({
title: "Tasks",
});
definePageMeta({
layout: "admin",
});
const { t } = useI18n();
const { runningTasks, historicalTasks, dailyTasks, weeklyTasks } =
await $dropFetch("/api/v1/admin/task");
const liveRunningTasks = ref(
await Promise.all(runningTasks.map((e) => useTask(e))),
);
const scheduledTasks: {
[key in TaskGroup]: { name: string; description: string };
} = {
"cleanup:invitations": {
name: t("tasks.admin.scheduled.cleanupInvitationsName"),
description: t("tasks.admin.scheduled.cleanupInvitationsDescription"),
},
"cleanup:objects": {
name: t("tasks.admin.scheduled.cleanupObjectsName"),
description: t("tasks.admin.scheduled.cleanupObjectsDescription"),
},
"cleanup:sessions": {
name: t("tasks.admin.scheduled.cleanupSessionsName"),
description: t("tasks.admin.scheduled.cleanupSessionsDescription"),
},
"check:update": {
name: t("tasks.admin.scheduled.checkUpdateName"),
description: t("tasks.admin.scheduled.checkUpdateDescription"),
},
"import:game": {
name: "",
description: "",
},
"import:version": {
name: "",
description: "",
},
};
async function startTask(taskGroup: string) {
const task = await $dropFetch("/api/v1/admin/task", {
method: "POST",
body: { taskGroup },
failTitle: "Failed to start task",
});
const taskRef = await useTask(task.id);
liveRunningTasks.value.push(taskRef);
}
</script>

View File

@ -1,166 +0,0 @@
<template>
<div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("header.admin.users") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("users.admin.description") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/users/auth"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t keypath="users.admin.authLink" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div
class="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow"
>
<table class="min-w-full divide-y divide-zinc-700">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
{{ $t("users.admin.displayNameHeader") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("users.admin.usernameHeader") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("users.admin.emailHeader") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("users.admin.adminHeader") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("users.admin.authoptionsHeader") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">
{{ $t("users.admin.srEditLabel") }}
</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700">
<tr
v-for="user in users"
:key="user.id"
class="hover:bg-zinc-800/50 transition-colors duration-150"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ user.displayName }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ user.username }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ user.email }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<span
:class="[
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
user.admin
? 'bg-blue-400/10 text-blue-400 ring-blue-400/20'
: 'bg-zinc-400/10 text-zinc-400 ring-zinc-400/20',
]"
>
{{
user.admin
? $t("users.admin.adminUserLabel")
: $t("users.admin.normalUserLabel")
}}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<div class="flex flex-wrap gap-1">
<span
v-for="mec in user.authMecs"
:key="mec.mec"
class="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20"
>
{{ mec.mec }}
</span>
</div>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
v-if="user.id !== currentUser?.id"
class="px-2 py-1 rounded bg-red-900/50 backdrop-blur-sm transition text-sm/6 font-semibold text-red-400 hover:text-red-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
@click="() => setUserToDelete(user)"
>
{{ $t("users.admin.delete") }}
</button>
<!--
<NuxtLink to="#" class="text-blue-600 hover:text-blue-500"
>Edit<span class="sr-only"
>, {{ user.displayName }}</span
></NuxtLink
>
-->
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<ModalDeleteUser v-model="userToDelete" />
</div>
</template>
<script setup lang="ts">
import type { UserModel } from "~~/prisma/client/models";
useHead({
title: "Users",
});
definePageMeta({
layout: "admin",
});
const users = useUsers();
const currentUser = useUser();
if (!users.value) {
await fetchUsers();
}
const userToDelete = ref();
const setUserToDelete = (user: UserModel) => (userToDelete.value = user);
</script>

View File

@ -1,289 +0,0 @@
<template>
<div class="flex min-h-screen flex-1 bg-zinc-900">
<div
class="flex flex-1 flex-col justify-center px-4 py-8 sm:px-6 lg:flex-none lg:px-20 xl:px-24"
>
<div class="mx-auto w-full max-w-sm lg:w-96">
<div>
<DropLogo class="h-8 w-auto" />
<h2
class="mt-4 text-2xl font-bold font-display leading-9 tracking-tight text-zinc-100"
>
{{ $t("auth.register.title") }}
</h2>
<p class="mt-1 text-sm leading-6 text-zinc-400">
{{ $t("auth.register.subheader") }}
</p>
</div>
<div class="mt-6">
<div class="bg-zinc-900">
<form class="space-y-4" @submit.prevent="() => register_wrapper()">
<div>
<label
for="display-name"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("auth.displayName") }}</label
>
<div class="mt-1">
<input
id="display-name"
v-model="displayName"
name="display-name"
type="text"
autocomplete="display-name"
required
placeholder="AwesomeDropGamer771"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label
for="email"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("auth.email") }}</label
>
<p
:class="[
validEmail ? 'text-blue-400' : 'text-red-500',
'block text-xs font-medium leading-6',
]"
>
{{ $t("auth.register.emailFormat") }}
</p>
<div class="mt-1">
<input
id="email"
v-model="email"
name="email"
type="email"
autocomplete="email"
required
:disabled="!!invitation?.email"
placeholder="me@example.com"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div class="w-full h-px bg-zinc-700" />
<div>
<label
for="username"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("auth.username") }}</label
>
<p
:class="[
validUsername ? 'text-blue-400' : 'text-red-500',
'block text-xs font-medium leading-6',
]"
>
{{ $t("auth.register.usernameFormat") }}
</p>
<div class="mt-1">
<input
id="username"
v-model="username"
name="username"
type="text"
autocomplete="username"
required
:disabled="!!invitation?.username"
placeholder="myUsername"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div class="w-full h-px bg-zinc-700" />
<div>
<label
for="password"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("auth.password") }}</label
>
<p
:class="[
validPassword ? 'text-blue-400' : 'text-red-500',
'block text-xs font-medium leading-6',
]"
>
{{ $t("auth.register.passwordFormat") }}
</p>
<div class="mt-1">
<input
id="password"
v-model="password"
name="password"
type="password"
autocomplete="password"
required
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label
for="confirm-password"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("auth.confirmPassword") }}</label
>
<p
:class="[
validConfirmPassword ? 'text-blue-400' : 'text-red-500',
'block text-xs font-medium leading-6',
]"
>
{{ $t("auth.register.confirmPasswordFormat") }}
</p>
<div class="mt-1">
<input
id="confirm-password"
v-model="confirmPassword"
name="confirm-password"
type="password"
autocomplete="confirm-password"
required
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<LoadingButton type="submit" :loading="loading" class="w-full">
{{ $t("common.create") }}
</LoadingButton>
</div>
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-3">
<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>
</div>
</div>
</div>
</div>
<div class="relative hidden w-0 flex-1 lg:block">
<img
src="/wallpapers/signin.jpg"
class="absolute inset-0 h-full w-full object-cover"
alt=""
/>
</div>
</div>
</template>
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/24/solid";
import { type } from "arktype";
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const invitationId = route.query.id?.toString();
if (!invitationId)
throw createError({
statusCode: 400,
message: t("errors.inviteRequired"),
});
const invitation = await $dropFetch(
`/api/v1/auth/signup/simple?id=${encodeURIComponent(invitationId)}`,
);
const email = ref(invitation?.email);
const displayName = ref("");
const username = ref(invitation?.username);
const password = ref("");
const confirmPassword = ref(undefined);
const emailValidator = type("string.email");
const validEmail = computed(
() => !((emailValidator(email.value) as unknown) instanceof type.errors),
);
const usernameValidator = type("string.alphanumeric >= 5").to("string.lower");
const validUsername = computed(
() =>
!((usernameValidator(username.value) as unknown) instanceof type.errors),
);
const passwordValidator = type("string >= 14");
const validPassword = computed(
() =>
!((passwordValidator(password.value) as unknown) instanceof type.errors),
);
const validConfirmPassword = computed(
() => password.value == confirmPassword.value,
);
const loading = ref(false);
const error = ref<string | undefined>(undefined);
async function register() {
await $dropFetch("/api/v1/auth/signup/simple", {
method: "POST",
body: {
invitation: invitationId,
username: username.value,
password: password.value,
email: email.value,
displayName: displayName.value,
},
});
}
function register_wrapper() {
if (
!validEmail.value ||
!validUsername.value ||
!validPassword.value ||
!validConfirmPassword.value
)
return;
loading.value = true;
register()
.then(() => {
if (route.query.after == "close") {
window.close();
return;
}
router.push("/auth/signin");
})
.catch((response) => {
const message = response.message || t("errors.unknown");
error.value = message;
})
.finally(() => {
loading.value = false;
});
}
definePageMeta({
layout: false,
});
useHead({
title: t("auth.register.title"),
});
</script>

View File

@ -1,135 +0,0 @@
<template>
<div class="w-full h-max min-h-[30vw] flex items-center justify-center">
<div class="text-center">
<h1 class="text-2xl font-bold text-zinc-100">
{{ $t("auth.code.title") }}
</h1>
<p class="mt-1 max-w-sm text-zinc-400 mx-auto">
{{ $t("auth.code.description") }}
</p>
<div v-if="!loading" class="mt-8 flex flex-row gap-4">
<input
v-for="i in codeLength"
ref="codeElements"
:key="i"
v-model="code[i - 1]"
class="uppercase w-16 h-16 appearance-none text-center bg-zinc-900 rounded-xl border-zinc-700 focus:border-blue-600 text-2xl text-bold font-display text-zinc-100"
type="text"
pattern="\d*"
:placeholder="placeholder[i - 1]"
@keydown="(v) => keydown(i - 1, v)"
@input="() => input(i - 1)"
@focusin="() => select(i - 1)"
@paste="(v) => paste(i - 1, v)"
/>
</div>
<div v-else class="mt-8 flex items-center justify-center">
<svg
aria-hidden="true"
class="size-12 text-transparent animate-spin fill-white"
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>
</div>
<div
v-if="error"
class="mt-8 rounded-md bg-red-600/10 p-4 max-w-sm mx-auto"
>
<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>
</div>
</template>
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/24/solid";
import { FetchError } from "ofetch";
const codeLength = 7;
const placeholder = "1A2B3C4";
const codeElements = useTemplateRef("codeElements");
const code = ref<string[]>([]);
const router = useRouter();
const loading = ref(false);
const error = ref<string | undefined>(undefined);
function keydown(index: number, event: KeyboardEvent) {
if (event.key === "Backspace" && !code.value[index] && index > 0) {
codeElements.value![index - 1].focus();
}
}
function input(index: number) {
if (codeElements.value === null) return;
const v = code.value[index] ?? "";
if (v.length > 1) code.value[index] = v[0];
if (!(index + 1 >= codeElements.value.length) && v) {
codeElements.value[index + 1].focus();
}
if (!(index - 1 < 0) && !v) {
codeElements.value[index - 1].focus();
}
console.log(index, codeLength - 1);
if (index == codeLength - 1) {
const assembledCode = code.value.join("");
if (assembledCode.length == codeLength) {
complete(assembledCode);
}
}
}
function select(index: number) {
if (!codeElements.value) return;
if (index >= codeElements.value.length) return;
codeElements.value[index]!.select();
}
function paste(index: number, event: ClipboardEvent) {
const newCode = event.clipboardData!.getData("text/plain");
for (let i = 0; i < newCode.length && i < codeLength; i++) {
code.value[i] = newCode[i]!;
codeElements.value![i]!.focus();
}
event.preventDefault();
}
async function complete(code: string) {
loading.value = true;
try {
const clientId = await $dropFetch(`/api/v1/client/auth/code?code=${code}`);
router.push(`/client/authorize/${clientId}`);
} catch (e) {
if (e instanceof FetchError) {
error.value =
e.message ?? "An unknown error occurred.";
} else {
error.value = (e as string)?.toString();
}
}
loading.value = false;
}
</script>

View File

@ -1,8 +0,0 @@
<template>
<div class="text-gray-100">{{ $t("todo") }}</div>
</template>
<script lang="ts" setup>
useHead({
title: "Settings",
});
</script>

View File

@ -1,233 +0,0 @@
<template>
<div class="min-h-screen flex flex-col">
<div class="grow grid grid-cols-1 lg:grid-cols-2">
<div class="border-b lg:border-b-0 lg:border-r border-zinc-700">
<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"
>
<DropWordmark />
</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>
<h1 class="text-4xl font-display font-bold text-zinc-100">
{{ $t("setup.welcome") }}
</h1>
<LanguageSelectorListbox :show-text="false" class="mt-4 max-w-sm" />
<p class="mt-6 text-zinc-400 max-w-xl">
{{ $t("setup.welcomeDescription") }}
</p>
</div>
<ul role="list" class="mt-10 divide-y divide-zinc-700/5">
<li
v-for="(action, actionIdx) in actions"
:key="action.name"
class="relative flex gap-x-6 py-6"
>
<div
class="flex size-10 flex-none items-center justify-center rounded-lg shadow-xs outline-1 outline-zinc-100/10"
>
<component
:is="action.icon"
v-if="!actionsComplete[actionIdx]"
class="size-6 text-blue-500"
aria-hidden="true"
/>
<CheckIcon v-else class="size-6 text-blue-500" />
</div>
<div class="flex-auto">
<h3 class="text-sm/6 font-semibold text-zinc-100">
<button
:class="
actionsComplete[actionIdx]
? 'line-through text-zinc-300'
: ''
"
@click="() => (currentAction = actionIdx)"
>
<span class="absolute inset-0" aria-hidden="true" />
{{ action.name }}
</button>
</h3>
<p class="mt-2 text-sm/6 text-zinc-400">
{{ action.description }}
</p>
</div>
<div class="flex-none self-center">
<ChevronRightIcon
class="size-5 text-gray-400"
aria-hidden="true"
/>
</div>
</li>
</ul>
<LoadingButton
:disabled="!finished"
:loading="finishLoading"
@click="() => finish()"
>
<i18n-t keypath="setup.finish" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</LoadingButton>
</main>
</div>
<component
:is="actions[currentAction].page"
v-if="actions[currentAction] && !useModal"
v-model="actionsComplete[currentAction]"
:token="bearerToken"
/>
<div
v-else-if="!useModal"
class="bg-zinc-950/30 flex items-center justify-center"
>
<!-- <p class="uppercase text-sm font-display text-zinc-700 font-bold">
{{ $t("setup.noPage") }}
</p> -->
<img
src="/wallpapers/signin.jpg"
class="inset-0 h-full w-full object-cover"
alt=""
preload
/>
</div>
</div>
<div>
<Transition>
<div v-if="useModal && open" class="relative z-10">
<div class="fixed inset-0 bg-zinc-900/75 transition-opacity" />
<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"
>
<div
class="relative transform overflow-hidden rounded-lg bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm"
>
<div>
<component
:is="actions[currentAction]!.page"
v-model="actionsComplete[currentAction]"
:token="bearerToken"
/>
</div>
<div class="mt-5 sm:mt-6 p-4">
<button
type="button"
class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-blue-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="currentAction = -1"
>
{{ $t("common.close") }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { SetupAccount, SetupLibrary } from "#components";
import {
CheckIcon,
ChevronRightIcon,
ServerStackIcon,
UserCircleIcon,
} from "@heroicons/vue/24/outline";
import type { Component } from "vue";
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core";
const breakpoints = useBreakpoints(breakpointsTailwind);
const useModal = computed(() => !breakpoints.lg.value);
const { t } = useI18n();
useHead({
title: t("setup.welcome"),
});
const route = useRoute();
const router = useRouter();
const token = route.query.token;
if (!token)
throw createError({
statusCode: 400,
message: "No token.",
fatal: true,
});
const bearerToken = `Bearer ${token}`;
const allowed = await $dropFetch("/api/v1/admin", {
headers: { Authorization: bearerToken },
});
if (!allowed)
throw createError({
statusCode: 400,
message: "Invalid setup token. Please check the URL you opened.",
fatal: true,
});
const currentAction = ref(-1);
const actions = ref<
Array<{
name: string;
description: string;
icon: Component;
page: Component;
}>
>([
{
name: t("setup.stages.account.name"),
description: t("setup.stages.account.description"),
icon: UserCircleIcon,
page: SetupAccount,
},
{
name: t("setup.stages.library.name"),
description: t("setup.stages.library.description"),
icon: ServerStackIcon,
page: SetupLibrary,
},
]);
const actionsComplete = ref(Array(actions.value.length).fill(false));
const finished = computed(
() => actionsComplete.value.filter((e) => !e).length == 0,
);
const open = computed(() => currentAction.value != -1);
definePageMeta({
layout: false,
});
const finishLoading = ref(false);
async function finish() {
currentAction.value = -1;
finishLoading.value = true;
await $dropFetch("/api/v1/setup", {
headers: { Authorization: bearerToken },
method: "POST",
});
router.push("/auth/signin");
}
</script>
<style scoped>
.v-enter-active,
.v-leave-active {
transition: opacity 0.2s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>

View File

@ -1,65 +0,0 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="w-full overflow-x-hidden">
<div class="relative overflow-hidden bg-zinc-900">
<!-- Decorative background image and gradient -->
<div aria-hidden="true" class="absolute inset-0">
<div class="absolute inset-0 overflow-hidden">
<img
:src="useObject(company.mBannerObjectId)"
alt=""
class="size-full object-cover"
/>
</div>
<div class="absolute inset-0 bg-zinc-900/75" />
<div class="absolute inset-0 bg-linear-to-t from-zinc-900" />
</div>
<!-- Callout -->
<section
aria-labelledby="sale-heading"
class="relative mx-auto flex max-w-7xl flex-col items-center px-4 pt-32 pb-8 text-center sm:px-6 lg:px-8"
>
<div class="mx-auto max-w-2xl lg:max-w-none">
<h2
id="sale-heading"
class="text-4xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl lg:text-6xl"
>
{{ company.mName }}
</h2>
<p class="mx-auto line-clamp-3 mt-4 max-w-xl text-xl text-zinc-400">
{{ company.mShortDescription }}
</p>
</div>
</section>
</div>
<StoreView
:extra-options="[
{
name: 'Company',
param: 'companyActions',
multiple: true,
options: [
{ name: 'Published', param: 'published' },
{ name: 'Developed', param: 'developed' },
],
},
]"
:params="{
company: company.id,
}"
/>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const companyId = route.params.id;
const { company } = await $dropFetch(`/api/v1/companies/${companyId}`);
useHead({
title: company.mName,
link: [{ rel: "icon", href: useObject(company.mLogoObjectId) }],
});
</script>

View File

@ -1,48 +0,0 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="w-full overflow-x-hidden">
<div class="relative overflow-hidden bg-zinc-900">
<!-- Decorative background image and gradient -->
<div aria-hidden="true" class="absolute inset-0">
<div class="absolute inset-0 overflow-hidden">
<img alt="" class="size-full object-cover" />
</div>
<div class="absolute inset-0 bg-zinc-900/75" />
<div class="absolute inset-0 bg-linear-to-t from-zinc-900" />
</div>
<!-- Callout -->
<section
aria-labelledby="sale-heading"
class="relative mx-auto flex max-w-7xl flex-col items-center px-4 pt-32 pb-8 text-center sm:px-6 lg:px-8"
>
<div class="mx-auto max-w-2xl lg:max-w-none">
<h2
id="sale-heading"
class="text-4xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl lg:text-6xl"
>
{{ tag.name }}
</h2>
</div>
</section>
</div>
<StoreView
:prefilled="{
tags: {
[tag.id]: true,
} as any,
}"
/>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const tagId = route.params.id;
const { tag } = await $dropFetch(`/api/v1/tags/${tagId}`);
useHead({
title: tag.name,
});
</script>

View File

@ -1,5 +0,0 @@
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook("vue:error", (error, instance, info) => {
console.error(info, error, instance);
});
});

View File

@ -1,31 +0,0 @@
import type { TaskLog } from "~~/server/internal/tasks";
const labelNumberMap = {
100: "silent",
60: "fatal",
50: "error",
40: "warn",
30: "info",
20: "debug",
10: "trace",
0: "off",
};
export function parseTaskLog(
logStr?: string | undefined,
): typeof TaskLog.infer {
if (!logStr) return { message: "", timestamp: "", level: "" };
const log = JSON.parse(logStr);
if (typeof log.level === "number") {
log.level = labelNumberMap[
log.level as keyof typeof labelNumberMap
] as string;
}
return {
message: log.msg,
timestamp: log.time,
level: log.level,
};
}

4
assets/tailwindcss.css Normal file
View File

@ -0,0 +1,4 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@config "../tailwind.config.js";

View File

@ -2,7 +2,8 @@
# This file starts up the Drop server by running migrations and then starting the executable
echo "[Drop] performing migrations..."
pnpm prisma migrate deploy
ls ./prisma/migrations/
prisma migrate deploy
# Actually start the application
node /app/app/server/index.mjs
node /app/app/server/index.mjs

352
changelog.md Normal file
View File

@ -0,0 +1,352 @@
## Release 0.2.0-beta
### Fixes
- fix recursive dirs util #02d6346
- Fix username length requirement #0a5a649
- remove dynamic imports #0f10626
- fix for missing developers or publishers #25fc957
- split prisma schemas #2859005
- results are returned alphabetically #33d3770
- update prisma schemas #36776cc
- removed global flag #43e32b4
- properly disconnect websockets from task handler #5358f1f
- follow best practices #54c5d55
- future lenience #5c78b20
- fix width of token breaking things #61d88c3
- fixed websocket authentication #62ea9a1
- fix delta manifest generation #6df560c
- admin invitation w/ system user #8463e35
- properly import icons #8945196
- prisma create footprint #952ece8
- game panel now always shows 3 lines exactly #9c2249e
- remove unnecessary import #a361c38
- fix disconnect code #a8f2106
- fix types #b511b40
- add drop-base as git submodule #b75ebd1
- Update README.md with discord link #c6bb21d
- fix expires requirement in the admin endpoint #c7b675f
- fix always being created as admin #c7eb11a
- moved icons and created PlatformClient so we can use the enum on the frontend #cada630
- recurse submodules #db103de
- fix FATAL: "root"... message #dbb315a
- only show versions that are directories #ef8f3ae
### Features
- update prisma & delete games #089c3e0
- manual handshake #12e3125
- fetch game endpoint #1f4d075
- under the hood organisation and consolidation #26a31f6
- 'no images' slide on image carousel #28baabc
- improve feedback when metadata fails #2c19e13
- introduction of 'system user' #2c21a23
- change name, description and icon #2cfe75a
- 'manual' metadata provider #2f52a16
- add disabled state #38fc6b8
- overhauled version importing #39d7ce7
- automatically create library folder if it doesn't exist #39fe9d5
- smoother bar in admin task ui #4488ae2
- add noWrapper option #4f9b949
- add version metadata route #5393db3
- completed admin UI, with minor changes to backend #599da0e
- adjust gradient #5a1f841
- keep track of last connected #69e4c25
- added notification system w/ interwoven refactoring #6e6f09d
- content length header for chunk downloads #76bceb1
- add title to tab #7b0756c
- add button to open in admin panel #7b3b919
- client capability framework + peer API configuration #7d72a86
- customisable image carousel and new layout #937954f
- support more types #9b12d45
- generate a server certificate for mtls APIs #9c4b6f3
- new endpoints, ui and beginnings of main store page #9cbdcbc
- backend #a309651
- more subtle design improvements #a815542
- add aden's carousel pagination design #a86045c
- add header #a8a152e
- client side search #b50e27f
- new ws handler #bc0c47c
- user widget now redirects to actual page #bfafe02
- require lowercase usernames #d7160ab
- more ui improvements #e408ac5
- add modifying game descriptions #e505e58
- mobile nav #e5cf13f
- slightly improved game page #e796b46
- game carousel #ecc819e
- add enum dictionary type #f2e0182
- improved ux #f3ed0f6
- cleanup and raw accessors #f7d767d
- add support for overriding UMU id #fd4a7d1
- add .sh for linux #fe9373a
### Other Changes
- quexeky <git@quexeky.dev>
- fixed manifest generation #03a37f7
- manual ci/cd #03b0b0c
- ability to fetch client certs for p2p #0a715fe
- disable tls in build #0f80fcd
- Updated README.md #17971e0
- Merge pull request #18 from Drop-OSS/develop
- initial work on metadata system #196f87c
- more ui #1bd19ad
- remove log statements #1d5e1bd
- small fixes & SSR disabled #1f575b2
- update information and setup guide #2236622
- metadata engine #22ac7f6
- Update CONTRIBUTING.md #2309407
- slight bug fixes and clean up #24a0d11
- almst complete admin ui and initial store designs #27070b6
- handshakes #2b4382d
- user mobile header #2e44ef3
- more consistent naming for globals #305de9f
- replaced markdown-it with micromark #31e8359
- fixes to store page for mobile clients #328b9ba
- game version re-ordering #329c74d
- verbose yarn install #36568c3
- patch for no version check in manifest generation #395219d
- migrate bcrypt to bcryptjs #3a51c9c
- added download chunk endpoint #3dd6062
- Update README.md #425934d
- build only ci #4273a20
- object storage + full permission system + testing #435551c
- rename admin socket session map #44c6028
- bump droplet and add vue carousel #46551f9
- version importing #46c8f0c
- back to yarn, with nuxt telemetry force disabled #46d35ad
- finished object endpoints #486bce8
- update dependencies and add note about optional dependencies #4fa771a
- use configuration from docs for ci/cd #52315d0
- slight fixes to register logic #583301f
- removed yarn.lock #584bcf1
- Version bump #5f29c28
- immutable application settings framework #5fe2036
- fixed docker daemon location #62a111b
- copy autodevops configuration #6328c24
- Delete .gitlab-ci.yml #69f341b
- admin ui shell #6b5e48d
- bump @drop/droplet version for windows developers #6ba5cdd
- Add LICENSE #6e2dc89
- custom dind #716eac7
- task API #718f5ba
- use gitlab ci variable declaration #7194d35
- move icons into dedicated folder #74fa671
- another stage of client authentication #7523e53
- refactoring #7869043
- moved windows logo into logos dir #789d3ba
- updated text colours across app #7a88f4c
- starting docs infra #7d2a1c6
- more cleaning #7e17626
- slight patch to rename query to be more consistent #7f4db0c
- move to raw docker #803752e
- server side and user client side completed for registration #848a611
- beginnings of download implementation #8674ac7
- more consistent naming for object handler #87230fb
- use autodevops build stage #886beb6
- Updated tailwind config #88c95d6
- change name of store file #8999303
- split prisma schemas #9011cf5
- client initiate #909432a
- more client routes to support Drop app update #91b7e10
- additional polish and QoL features #93bc143
- upload images to games #9b7ee4e
- migrate to pnpm due to ci/cd issues with yarn #9cb2d6d
- run yarn install in CI/CD non interactively #a208fbe
- completed game importing; partial work on version importing #a7c33e7
- remove canvas from dependencies #a8f58eb
- fix registry authentication #ad25d3e
- consolidate type utils #adb4b73
- Updated README.md #b0ef675
- add proper carousel to store page #b2ab827
- move to yarn v2 #b744671
- remove client API deadweight #b9ae26c
- add expires field #be6c30d
- ca groundwork #bfafd2a
- cleanup & polish #c355f6f
- remove bcrypt (debug) #c3914cc
- non rounded bottom #c4391d3
- failed gracefully on invalid chunk index #c4a3e4e
- update deploy template #c4a419f
- migrate to new droplet ca system #c4d8113
- docker based deployment #c5d00b4
- updated CONTRIBUTING.md #cd0d2bf
- update prisma version #ce0a9ab
- README update #ceacd84
- patch metadata handler #cf578bd
- Added SECURITY.md #d3d93b0
- finalised client APIs and authentication method #d4e2dc8
- Update README.md #db916bf
- object storage interface + utility functions #de388a9
- initial commit #e1a789f
- fixed task system #e1c1d7e
- Update file chunk.get.ts #e4339c3
- ui groundwork #e52f072
- Update changelog #eadcaa1
- check for no version in manifest generation #eb3f9f9
- break into single column store on lg devices #ecb381e
- better server side signin redirects #ef13b68
- patch signin #f3672f8
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)
## Release 0.1.0-beta
### Fixes
- remove dynamic imports #0f10626
- fix for missing developers or publishers #25fc957
- split prisma schemas #2859005
- results are returned alphabetically #33d3770
- properly disconnect websockets from task handler #5358f1f
- follow best practices #54c5d55
- future lenience #5c78b20
- fixed websocket authentication #62ea9a1
- fix delta manifest generation #6df560c
- admin invitation w/ system user #8463e35
- properly import icons #8945196
- prisma create footprint #952ece8
- game panel now always shows 3 lines exactly #9c2249e
- remove unnecessary import #a361c38
- fix types #b511b40
- fix expires requirement in the admin endpoint #c7b675f
- moved icons and created PlatformClient so we can use the enum on the frontend #cada630
- only show versions that are directories #ef8f3ae
### Features
- update prisma & delete games #089c3e0
- fetch game endpoint #1f4d075
- under the hood organisation and consolidation #26a31f6
- introduction of 'system user' #2c21a23
- automatically create library folder if it doesn't exist #39fe9d5
- smoother bar in admin task ui #4488ae2
- add version metadata route #5393db3
- completed admin UI, with minor changes to backend #599da0e
- keep track of last connected #69e4c25
- added notification system w/ interwoven refactoring #6e6f09d
- content length header for chunk downloads #76bceb1
- add title to tab #7b0756c
- add button to open in admin panel #7b3b919
- client capability framework + peer API configuration #7d72a86
- generate a server certificate for mtls APIs #9c4b6f3
- new endpoints, ui and beginnings of main store page #9cbdcbc
- more subtle design improvements #a815542
- add header #a8a152e
- client side search #b50e27f
- new ws handler #bc0c47c
- user widget now redirects to actual page #bfafe02
- require lowercase usernames #d7160ab
- more ui improvements #e408ac5
- slightly improved game page #e796b46
- game carousel #ecc819e
- add enum dictionary type #f2e0182
- cleanup and raw accessors #f7d767d
- add support for overriding UMU id #fd4a7d1
### Other Changes
- quexeky <git@quexeky.dev>
- fixed manifest generation #03a37f7
- manual ci/cd #03b0b0c
- ability to fetch client certs for p2p #0a715fe
- disable tls in build #0f80fcd
- Updated README.md #17971e0
- initial work on metadata system #196f87c
- more ui #1bd19ad
- remove log statements #1d5e1bd
- small fixes & SSR disabled #1f575b2
- update information and setup guide #2236622
- metadata engine #22ac7f6
- Update CONTRIBUTING.md #2309407
- slight bug fixes and clean up #24a0d11
- almst complete admin ui and initial store designs #27070b6
- handshakes #2b4382d
- user mobile header #2e44ef3
- more consistent naming for globals #305de9f
- replaced markdown-it with micromark #31e8359
- fixes to store page for mobile clients #328b9ba
- game version re-ordering #329c74d
- verbose yarn install #36568c3
- patch for no version check in manifest generation #395219d
- migrate bcrypt to bcryptjs #3a51c9c
- added download chunk endpoint #3dd6062
- Update README.md #425934d
- build only ci #4273a20
- object storage + full permission system + testing #435551c
- rename admin socket session map #44c6028
- bump droplet and add vue carousel #46551f9
- version importing #46c8f0c
- back to yarn, with nuxt telemetry force disabled #46d35ad
- finished object endpoints #486bce8
- update dependencies and add note about optional dependencies #4fa771a
- use configuration from docs for ci/cd #52315d0
- slight fixes to register logic #583301f
- removed yarn.lock #584bcf1
- Version bump #5f29c28
- immutable application settings framework #5fe2036
- fixed docker daemon location #62a111b
- copy autodevops configuration #6328c24
- Delete .gitlab-ci.yml #69f341b
- admin ui shell #6b5e48d
- bump @drop/droplet version for windows developers #6ba5cdd
- Add LICENSE #6e2dc89
- task API #718f5ba
- use gitlab ci variable declaration #7194d35
- move icons into dedicated folder #74fa671
- another stage of client authentication #7523e53
- refactoring #7869043
- moved windows logo into logos dir #789d3ba
- updated text colours across app #7a88f4c
- starting docs infra #7d2a1c6
- more cleaning #7e17626
- slight patch to rename query to be more consistent #7f4db0c
- move to raw docker #803752e
- server side and user client side completed for registration #848a611
- beginnings of download implementation #8674ac7
- more consistent naming for object handler #87230fb
- use autodevops build stage #886beb6
- Updated tailwind config #88c95d6
- change name of store file #8999303
- split prisma schemas #9011cf5
- client initiate #909432a
- more client routes to support Drop app update #91b7e10
- additional polish and QoL features #93bc143
- upload images to games #9b7ee4e
- migrate to pnpm due to ci/cd issues with yarn #9cb2d6d
- run yarn install in CI/CD non interactively #a208fbe
- completed game importing; partial work on version importing #a7c33e7
- remove canvas from dependencies #a8f58eb
- fix registry authentication #ad25d3e
- consolidate type utils #adb4b73
- Updated README.md #b0ef675
- add proper carousel to store page #b2ab827
- move to yarn v2 #b744671
- remove client API deadweight #b9ae26c
- add expires field #be6c30d
- ca groundwork #bfafd2a
- cleanup & polish #c355f6f
- remove bcrypt (debug) #c3914cc
- non rounded bottom #c4391d3
- failed gracefully on invalid chunk index #c4a3e4e
- update deploy template #c4a419f
- migrate to new droplet ca system #c4d8113
- docker based deployment #c5d00b4
- updated CONTRIBUTING.md #cd0d2bf
- update prisma version #ce0a9ab
- README update #ceacd84
- patch metadata handler #cf578bd
- Added SECURITY.md #d3d93b0
- finalised client APIs and authentication method #d4e2dc8
- Update README.md #db916bf
- object storage interface + utility functions #de388a9
- initial commit #e1a789f
- fixed task system #e1c1d7e
- Update file chunk.get.ts #e4339c3
- ui groundwork #e52f072
- check for no version in manifest generation #eb3f9f9
- break into single column store on lg devices #ecb381e
- better server side signin redirects #ef13b68
- patch signin #f3672f8
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)

View File

@ -1,7 +1,7 @@
<template>
<div class="flex grow flex-col gap-y-5 overflow-y-auto px-6 py-4">
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
<UserIcon class="size-5" /> {{ $t("account.title") }}
<UserIcon class="size-5" /> Account Settings
</span>
<nav class="flex flex-1 flex-col">
<ul role="list" class="flex flex-1 flex-col gap-y-7">
@ -45,43 +45,35 @@ import {
LockClosedIcon,
DevicePhoneMobileIcon,
WrenchScrewdriverIcon,
CodeBracketIcon,
} from "@heroicons/vue/24/outline";
import { UserIcon } from "@heroicons/vue/24/solid";
import type { Component } from "vue";
const notifications = useNotifications();
const { t } = useI18n();
const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
{ label: t("home"), route: "/account", icon: HomeIcon, prefix: "/account" },
{ label: "Home", route: "/account", icon: HomeIcon, prefix: "/account" },
{
label: t("security"),
label: "Security",
route: "/account/security",
prefix: "/account/security",
icon: LockClosedIcon,
},
{
label: t("account.devices.title"),
label: "Devices",
route: "/account/devices",
prefix: "/account/devices",
icon: DevicePhoneMobileIcon,
},
{
label: t("account.notifications.notifications"),
label: "Notifications",
route: "/account/notifications",
prefix: "/account/notifications",
icon: BellIcon,
count: notifications.value.length,
},
{
label: t("account.token.title"),
route: "/account/tokens",
prefix: "/account/tokens",
icon: CodeBracketIcon,
},
{
label: t("account.settings"),
label: "Settings",
route: "/account/settings",
prefix: "/account/settings",
icon: WrenchScrewdriverIcon,

View File

@ -8,7 +8,7 @@
class="transition w-full inline-flex items-center justify-center h-full gap-x-2 rounded-none rounded-l-md bg-white/10 hover:bg-white/20 text-zinc-100 backdrop-blur px-5 py-3 active:scale-95"
@click="() => toggleLibrary()"
>
{{ inLibrary ? $t("library.inLib") : $t("library.addToLib") }}
{{ inLibrary ? "In Library" : "Add to Library" }}
<CheckIcon v-if="inLibrary" class="-mr-0.5 h-5 w-5" aria-hidden="true" />
<PlusIcon v-else class="-mr-0.5 h-5 w-5" aria-hidden="true" />
</LoadingButton>
@ -36,7 +36,7 @@
<div
class="font-display uppercase px-3 py-2 text-sm font-semibold text-zinc-500"
>
{{ $t("library.collection.collections") }}
Collections
</div>
<div
class="flex flex-col gap-y-2 py-1 max-h-[150px] overflow-y-auto"
@ -45,7 +45,7 @@
v-if="collections.length === 0"
class="px-3 py-2 text-sm text-zinc-500"
>
{{ $t("library.collection.noCollections") }}
No collections
</div>
<MenuItem
v-for="(collection, collectionIdx) in collections"
@ -75,7 +75,7 @@
@click="createCollectionModal = true"
>
<PlusIcon class="mr-2 h-4 w-4" />
{{ $t("library.collection.addToNew") }}
Add to new collection
</LoadingButton>
</div>
</div>
@ -84,7 +84,7 @@
</Menu>
</div>
<ModalCreateCollection
<CreateCollectionModal
v-model="createCollectionModal"
:game-id="props.gameId"
/>
@ -100,7 +100,6 @@ const props = defineProps<{
const isLibraryLoading = ref(false);
const { t } = useI18n();
const createCollectionModal = ref(false);
const collections = await useCollections();
const library = await useLibrary();
@ -122,9 +121,18 @@ async function toggleLibrary() {
body: {
id: props.gameId,
},
failTitle: t("errors.library.add.title"),
});
await refreshLibrary();
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to add game to library",
// @ts-expect-error attempt to display statusMessage on error
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
},
(_, c) => c(),
);
} finally {
isLibraryLoading.value = false;
}
@ -136,18 +144,24 @@ async function toggleCollection(id: string) {
if (!collection) return;
const index = collection.entries.findIndex((e) => e.gameId == props.gameId);
await $dropFetch(`/api/v1/collection/:id/entry`, {
await $dropFetch(`/api/v1/collection/${id}/entry`, {
method: index == -1 ? "POST" : "DELETE",
params: { id },
body: {
id: props.gameId,
},
failTitle: t("errors.library.add.title"),
});
await refreshCollection(id);
} finally {
/* empty */
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to add game to library",
// @ts-expect-error attempt to display statusMessage on error
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
},
(_, c) => c(),
);
}
}
</script>

View File

@ -0,0 +1,10 @@
<template>
<div class="flex">
<a
href="/auth/oidc"
class="transition rounded-md grow inline-flex items-center justify-center bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-white/20"
>
Sign in with external provider &rarr;
</a>
</div>
</template>

View File

@ -4,7 +4,7 @@
<label
for="username"
class="block text-sm font-medium leading-6 text-zinc-300"
>{{ $t("auth.username") }}</label
>Username</label
>
<div class="mt-2">
<input
@ -23,7 +23,7 @@
<label
for="password"
class="block text-sm font-medium leading-6 text-zinc-300"
>{{ $t("auth.password") }}</label
>Password</label
>
<div class="mt-2">
<input
@ -50,23 +50,19 @@
<label
for="remember-me"
class="ml-3 block text-sm leading-6 text-zinc-400"
>{{ $t("auth.signin.rememberMe") }}</label
>Remember me</label
>
</div>
<div class="text-sm leading-6">
<NuxtLink
to="#"
class="font-semibold text-blue-600 hover:text-blue-500"
>{{ $t("auth.signin.forgot") }}</NuxtLink
<NuxtLink to="#" class="font-semibold text-blue-600 hover:text-blue-500"
>Forgot password?</NuxtLink
>
</div>
</div>
<div>
<LoadingButton class="w-full" :loading="loading">{{
$t("auth.signin.signin")
}}</LoadingButton>
<LoadingButton class="w-full" :loading="loading"> Sign in</LoadingButton>
</div>
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-4">
@ -86,7 +82,7 @@
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid";
import type { UserModel } from "~~/prisma/client/models";
import type { User } from "~/prisma/client";
const username = ref("");
const password = ref("");
@ -97,7 +93,6 @@ const error = ref<string | undefined>();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
function signin_wrapper() {
loading.value = true;
@ -106,7 +101,7 @@ function signin_wrapper() {
router.push(route.query.redirect?.toString() ?? "/");
})
.catch((response) => {
const message = response.message || t("errors.unknown");
const message = response.statusMessage || "An unknown error occurred";
error.value = message;
})
.finally(() => {
@ -124,6 +119,6 @@ async function signin() {
},
});
const user = useUser();
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
user.value = await $dropFetch<User | null>("/api/v1/user");
}
</script>

View File

@ -3,10 +3,11 @@
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.collection.create") }}
Create collection
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.collection.createDesc") }}
Collections can used to organise your games and find them more easily,
especially if you have a large library.
</p>
</div>
<div class="mt-2">
@ -14,7 +15,7 @@
<input
v-model="collectionName"
type="text"
:placeholder="$t('library.collection.namePlaceholder')"
placeholder="Collection name"
class="block w-full rounded-md border-0 bg-zinc-800 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
<button class="hidden" type="submit" />
@ -29,7 +30,7 @@
class="w-full sm:w-fit"
@click="() => createCollection()"
>
{{ $t("common.create") }}
Create
</LoadingButton>
<button
ref="cancelButtonRef"
@ -37,7 +38,7 @@
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="() => close()"
>
{{ $t("cancel") }}
Cancel
</button>
</template>
</ModalTemplate>
@ -46,7 +47,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { DialogTitle } from "@headlessui/vue";
import type { CollectionEntryModel, GameModel } from "~~/prisma/client/models";
import type { CollectionEntry, Game } from "~/prisma/client";
import type { SerializeObject } from "nitropack";
const props = defineProps<{
@ -59,7 +60,6 @@ const emit = defineEmits<{
const open = defineModel<boolean>({ required: true });
const { t } = useI18n();
const collectionName = ref("");
const createCollectionLoading = ref(false);
const collections = await useCollections();
@ -79,7 +79,7 @@ async function createCollection() {
// Add the game if provided
if (props.gameId) {
const entry = await $dropFetch<
CollectionEntryModel & { game: SerializeObject<GameModel> }
CollectionEntry & { game: SerializeObject<Game> }
>(`/api/v1/collection/${response.id}/entry`, {
method: "POST",
body: { id: props.gameId },
@ -97,14 +97,12 @@ async function createCollection() {
} catch (error) {
console.error("Failed to create collection:", error);
const err = error as { message?: string };
const err = error as { statusMessage?: string };
createModal(
ModalType.Notification,
{
title: t("errors.library.collection.create.title"),
description: t("errors.library.collection.create.desc", [
err?.message ?? t("errors.unknown"),
]),
title: "Failed to create collection",
description: `Drop couldn't create your collection: ${err?.statusMessage}`,
},
(_, c) => c(),
);

View File

@ -6,13 +6,13 @@
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
{{ $t("library.collection.delete") }}
Delete Collection
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
{{ $t("common.deleteConfirm", [collection?.name]) }}
Are you sure you want to delete "{{ collection?.name }}"?
</p>
<p class="mt-2 text-sm font-bold text-red-500">
{{ $t("common.cannotUndo") }}
This action cannot be undone.
</p>
</div>
</template>
@ -22,38 +22,35 @@
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteCollection()"
>
{{ $t("delete") }}
Delete
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (collection = undefined)"
>
{{ $t("cancel") }}
Cancel
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import type { CollectionModel } from "~~/prisma/client/models";
import type { Collection } from "~/prisma/client";
import { DialogTitle } from "@headlessui/vue";
const collection = defineModel<CollectionModel | undefined>();
const collection = defineModel<Collection | undefined>();
const deleteLoading = ref(false);
const collections = await useCollections();
const { t } = useI18n();
async function deleteCollection() {
try {
if (!collection.value) return;
deleteLoading.value = true;
await $dropFetch(`/api/v1/collection/:id`, {
await $dropFetch(`/api/v1/collection/${collection.value.id}`, {
// @ts-expect-error not documented
method: "DELETE",
params: {
id: collection.value.id,
},
});
const index = collections.value.findIndex(
(e) => e.id == collection.value?.id,
@ -65,11 +62,9 @@ async function deleteCollection() {
createModal(
ModalType.Notification,
{
title: t("errors.library.add.title"),
description: t("errors.library.add.desc", [
// @ts-expect-error attempt to display message on error
e?.message ?? t("errors.unknown"),
]),
title: "Failed to add game to library",
// @ts-expect-error attempt to display statusMessage on error
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
},
(_, c) => c(),
);

View File

@ -6,13 +6,13 @@
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
{{ $t("news.delete") }}
Delete Article
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
{{ $t("common.deleteConfirm", [article?.title]) }}
Are you sure you want to delete "{{ article?.title }}"?
</p>
<p class="mt-2 text-sm font-bold text-red-500">
{{ $t("common.cannotUndo") }}
This action cannot be undone.
</p>
</div>
</template>
@ -22,13 +22,13 @@
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteArticle()"
>
{{ $t("delete") }}
Delete
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (article = undefined)"
>
{{ $t("cancel") }}
Cancel
</button>
</template>
</ModalTemplate>
@ -45,7 +45,6 @@ interface Article {
const article = defineModel<Article | undefined>();
const deleteLoading = ref(false);
const router = useRouter();
const { t } = useI18n();
const news = useNews();
if (!news.value) {
news.value = await fetchNews();
@ -69,11 +68,9 @@ async function deleteArticle() {
createModal(
ModalType.Notification,
{
title: t("errors.news.article.delete.title"),
description: t("errors.news.article.delete.desc", [
// @ts-expect-error attempt to display message on error
e?.message ?? t("errors.unknown"),
]),
title: "Failed to delete article",
// @ts-expect-error attempt to display statusMessage on error
description: `Drop couldn't delete this article: ${e?.statusMessage}`,
},
(_, c) => c(),
);

View File

@ -1,6 +1,5 @@
<template>
<svg
aria-label="Drop Logo"
class="text-blue-400"
viewBox="0 0 24 24"
fill="none"
@ -10,16 +9,6 @@
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
stroke="currentColor"
stroke-width="2"
stroke-dasharray="100"
:stroke-dashoffset="dashArray"
/>
</svg>
</template>
<script setup lang="ts">
const props = defineProps<{ progress?: number }>();
const dashArray = computed(() =>
props.progress === undefined ? 0 : ((100 - props.progress) / 100) * 50 + 50,
);
</script>

View File

@ -10,9 +10,9 @@
d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z"
/>
</svg>
<DropLogo aria-hidden="true" class="h-6" />
<span class="text-blue-400 font-display font-bold text-xl uppercase">
{{ $t("drop.drop") }}
</span>
<DropLogo class="h-6" />
<span class="text-blue-400 font-display font-bold text-xl uppercase"
>Drop</span
>
</div>
</template>

View File

@ -7,11 +7,7 @@
:key="gameIdx"
class="justify-start"
>
<GamePanel
:game="game"
:href="game ? `/store/${game.id}` : undefined"
:show-title-description="showGamePanelTextDecoration"
/>
<GamePanel :game="game" />
</VueSlide>
<template #addons>
@ -35,21 +31,19 @@
</template>
<script setup lang="ts">
import type { GameModel } from "~~/prisma/client/models";
import type { Game } from "~/prisma/client";
import type { SerializeObject } from "nitropack";
const props = defineProps<{
items: Array<SerializeObject<GameModel>>;
items: Array<SerializeObject<Game>>;
min?: number;
width?: number;
}>();
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
const currentComponent = ref<HTMLDivElement>();
const min = computed(() => Math.max(props.min ?? 8, props.items.length));
const games: Ref<Array<SerializeObject<GameModel> | undefined>> = computed(() =>
const games: Ref<Array<SerializeObject<Game> | undefined>> = computed(() =>
Array(min.value)
.fill(0)
.map((_, i) => props.items[i]),

61
components/GamePanel.vue Normal file
View File

@ -0,0 +1,61 @@
<template>
<NuxtLink
v-if="game"
:href="props.href ?? `/store/${game.id}`"
class="group relative w-48 h-64 rounded-lg overflow-hidden transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5"
@click="active = game.id"
>
<div
class="absolute inset-0 transition-all duration-300 group-hover:scale-110"
>
<img
:src="useObject(game.mCoverObjectId)"
class="w-full h-full object-cover brightness-[90%]"
:class="{ active: active === game.id }"
:alt="game.mName"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/20 to-transparent"
/>
</div>
<div class="absolute bottom-0 left-0 w-full p-3">
<h1
class="text-zinc-100 text-sm font-bold font-display group-hover:text-white transition-colors"
>
{{ game.mName }}
</h1>
<p
class="text-zinc-400 text-xs line-clamp-2 group-hover:text-zinc-300 transition-colors"
>
{{ game.mShortDescription }}
</p>
</div>
</NuxtLink>
<SkeletonCard v-else message="no game" />>
</template>
<script setup lang="ts">
import type { SerializeObject } from "nitropack";
const props = defineProps<{
game:
| SerializeObject<{
id: string;
mCoverObjectId: string;
mName: string;
mShortDescription: string;
}>
| undefined;
href?: string;
}>();
const active = useState();
</script>
<style scoped>
img.active {
view-transition-name: selected-game;
contain: layout;
}
</style>

View File

@ -16,9 +16,9 @@
</template>
<script setup lang="ts">
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const { game } = defineProps<{
game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };
game: GameMetadataSearchResult & { sourceName?: string };
}>();
</script>

Some files were not shown because too many files have changed in this diff Show More