i18n Support and Task improvements (#80)

* fix: release workflow

* feat: move mostly to internal tasks system

* feat: migrate object clean to new task system

* fix: release not  getting good base version

* chore: set version v0.3.0

* chore: style

* feat: basic task concurrency

* feat: temp pages to fill in page links

* feat: inital i18n support

* feat: localize store page

* chore: style

* fix: weblate doesn't like multifile thing

* fix: update nuxt

* feat: improved error logging

* fix: using old task api

* feat: basic translation docs

* feat: add i18n eslint plugin

* feat: translate store and auth pages

* feat: more translation progress

* feat: admin dash i18n progress

* feat: enable update check by default in prod

* fix: using wrong i18n keys

* fix: crash in library sources page

* feat: finish i18n work

* fix: missing i18n translations

* feat: use twemoji for emojis

* feat: sanatize object ids

* fix: EmojiText's alt text

* fix: UserWidget not using links

* feat: cache and auth for emoji api

* fix: add more missing translations
This commit is contained in:
Husky
2025-06-04 19:53:30 -04:00
committed by GitHub
parent c7fab132ab
commit 681efe95af
86 changed files with 5175 additions and 2816 deletions

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" /> Account Settings
<UserIcon class="size-5" /> {{ $t("account.settings") }}
</span>
<nav class="flex flex-1 flex-col">
<ul role="list" class="flex flex-1 flex-col gap-y-7">
@ -50,30 +50,31 @@ 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: "Home", route: "/account", icon: HomeIcon, prefix: "/account" },
{ label: t("home"), route: "/account", icon: HomeIcon, prefix: "/account" },
{
label: "Security",
label: t("security"),
route: "/account/security",
prefix: "/account/security",
icon: LockClosedIcon,
},
{
label: "Devices",
label: t("account.devices.title"),
route: "/account/devices",
prefix: "/account/devices",
icon: DevicePhoneMobileIcon,
},
{
label: "Notifications",
label: t("account.notifications.notifications"),
route: "/account/notifications",
prefix: "/account/notifications",
icon: BellIcon,
count: notifications.value.length,
},
{
label: "Settings",
label: t("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 ? "In Library" : "Add to Library" }}
{{ inLibrary ? $t("library.inLib") : $t("library.addToLib") }}
<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"
>
Collections
{{ $t("library.collection.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"
>
No collections
{{ $t("library.collection.noCollections") }}
</div>
<MenuItem
v-for="(collection, collectionIdx) in collections"
@ -75,7 +75,7 @@
@click="createCollectionModal = true"
>
<PlusIcon class="mr-2 h-4 w-4" />
Add to new collection
{{ $t("library.collection.addToNew") }}
</LoadingButton>
</div>
</div>
@ -100,6 +100,7 @@ const props = defineProps<{
const isLibraryLoading = ref(false);
const { t } = useI18n();
const createCollectionModal = ref(false);
const collections = await useCollections();
const library = await useLibrary();
@ -127,9 +128,11 @@ async function toggleLibrary() {
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}`,
title: t("errors.library.add.title"),
description: t("errors.library.add.desc", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
@ -156,9 +159,11 @@ async function toggleCollection(id: string) {
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}`,
title: t("errors.library.add.title"),
description: t("errors.library.add.desc", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);

View File

@ -4,7 +4,11 @@
: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"
>
Sign in with external provider &rarr;
<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>

View File

@ -4,7 +4,7 @@
<label
for="username"
class="block text-sm font-medium leading-6 text-zinc-300"
>Username</label
>{{ $t("auth.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"
>Password</label
>{{ $t("auth.password") }}</label
>
<div class="mt-2">
<input
@ -50,19 +50,23 @@
<label
for="remember-me"
class="ml-3 block text-sm leading-6 text-zinc-400"
>Remember me</label
>{{ $t("auth.signin.rememberMe") }}</label
>
</div>
<div class="text-sm leading-6">
<NuxtLink to="#" class="font-semibold text-blue-600 hover:text-blue-500"
>Forgot password?</NuxtLink
<NuxtLink
to="#"
class="font-semibold text-blue-600 hover:text-blue-500"
>{{ $t("auth.signin.forgot") }}</NuxtLink
>
</div>
</div>
<div>
<LoadingButton class="w-full" :loading="loading"> Sign in</LoadingButton>
<LoadingButton class="w-full" :loading="loading">{{
$t("auth.signin.signin")
}}</LoadingButton>
</div>
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-4">
@ -93,6 +97,7 @@ const error = ref<string | undefined>();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
function signin_wrapper() {
loading.value = true;
@ -101,7 +106,7 @@ function signin_wrapper() {
router.push(route.query.redirect?.toString() ?? "/");
})
.catch((response) => {
const message = response.statusMessage || "An unknown error occurred";
const message = response.statusMessage || t("errors.unknown");
error.value = message;
})
.finally(() => {

View File

@ -3,11 +3,10 @@
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
Create collection
{{ $t("library.collection.create") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
Collections can used to organise your games and find them more easily,
especially if you have a large library.
{{ $t("library.collection.createDesc") }}
</p>
</div>
<div class="mt-2">
@ -15,7 +14,7 @@
<input
v-model="collectionName"
type="text"
placeholder="Collection name"
:placeholder="$t('library.collection.namePlaceholder')"
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" />
@ -30,7 +29,7 @@
class="w-full sm:w-fit"
@click="() => createCollection()"
>
Create
{{ $t("create") }}
</LoadingButton>
<button
ref="cancelButtonRef"
@ -38,7 +37,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()"
>
Cancel
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
@ -60,6 +59,7 @@ const emit = defineEmits<{
const open = defineModel<boolean>({ required: true });
const { t } = useI18n();
const collectionName = ref("");
const createCollectionLoading = ref(false);
const collections = await useCollections();
@ -101,8 +101,10 @@ async function createCollection() {
createModal(
ModalType.Notification,
{
title: "Failed to create collection",
description: `Drop couldn't create your collection: ${err?.statusMessage}`,
title: t("errors.library.collection.create.title"),
description: t("errors.library.collection.create.desc", [
err?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);

View File

@ -6,13 +6,13 @@
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
Delete Collection
{{ $t("library.collection.delete") }}
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
Are you sure you want to delete "{{ collection?.name }}"?
{{ $t("common.deleteConfirm", [collection?.name]) }}
</p>
<p class="mt-2 text-sm font-bold text-red-500">
This action cannot be undone.
{{ $t("common.cannotUndo") }}
</p>
</div>
</template>
@ -22,13 +22,13 @@
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteCollection()"
>
Delete
{{ $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="() => (collection = undefined)"
>
Cancel
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
@ -42,6 +42,7 @@ const collection = defineModel<Collection | undefined>();
const deleteLoading = ref(false);
const collections = await useCollections();
const { t } = useI18n();
async function deleteCollection() {
try {
@ -62,9 +63,11 @@ async function deleteCollection() {
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}`,
title: t("errors.library.add.title"),
description: t("errors.library.add.desc", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);

View File

@ -6,13 +6,13 @@
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
Delete Article
{{ $t("news.delete") }}
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
Are you sure you want to delete "{{ article?.title }}"?
{{ $t("common.deleteConfirm", [article?.title]) }}
</p>
<p class="mt-2 text-sm font-bold text-red-500">
This action cannot be undone.
{{ $t("common.cannotUndo") }}
</p>
</div>
</template>
@ -22,13 +22,13 @@
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteArticle()"
>
Delete
{{ $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="() => (article = undefined)"
>
Cancel
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
@ -45,6 +45,7 @@ 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();
@ -68,9 +69,11 @@ async function deleteArticle() {
createModal(
ModalType.Notification,
{
title: "Failed to delete article",
// @ts-expect-error attempt to display statusMessage on error
description: `Drop couldn't delete this article: ${e?.statusMessage}`,
title: t("errors.news.article.delete.title"),
description: t("errors.news.article.delete.desc", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);

View File

@ -11,8 +11,8 @@
/>
</svg>
<DropLogo class="h-6" />
<span class="text-blue-400 font-display font-bold text-xl uppercase"
>Drop</span
>
<span class="text-blue-400 font-display font-bold text-xl uppercase">
{{ $t("drop.drop") }}
</span>
</div>
</template>

17
components/EmojiText.vue Normal file
View File

@ -0,0 +1,17 @@
<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 emojiEl = ref<HTMLElement | null>(null);
const url = computed(() => {
return `/api/v1/emojis/${twemoji.convert.toCodePoint(props.emoji)}.svg`;
});
</script>

View File

@ -32,7 +32,7 @@
</p>
</div>
</NuxtLink>
<SkeletonCard v-else message="no game" />>
<SkeletonCard v-else :message="$t('store.noGame')" />
</template>
<script setup lang="ts">

View File

@ -1,7 +1,7 @@
<template>
<div class="flex grow flex-col overflow-y-auto px-6 py-4">
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
<Bars3Icon class="size-6" /> Library
<Bars3Icon class="size-6" /> {{ $t("userHeader.links.library") }}
</span>
<!-- Search bar -->
@ -13,7 +13,7 @@
name="search"
autocomplete="off"
class="block w-full rounded-md bg-zinc-900 py-2 pl-9 pr-2 text-sm 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"
placeholder="Search library..."
placeholder="$t('library.search')"
/>
<MagnifyingGlassIcon
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400"
@ -53,7 +53,7 @@
v-else
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
>
{{ !!searchQuery ? "No results" : "No games in library" }}
{{ !!searchQuery ? $t("common.noResults") : $t("library.noGames") }}
</p>
</div>
</template>

View File

@ -11,18 +11,18 @@
class="h-5 w-5 transition-transform duration-200"
:class="{ 'rotate-90': modalOpen }"
/>
<span>New article</span>
<span>{{ $t("news.article.new") }}</span>
</button>
<ModalTemplate v-model="modalOpen" size-class="sm:max-w-[80vw]">
<h3 class="text-lg font-semibold text-zinc-100 mb-4">
Create New Article
{{ $t("news.article.create") }}
</h3>
<form class="space-y-4" @submit.prevent="() => createArticle()">
<div>
<label for="title" class="block text-sm font-medium text-zinc-400"
>Title</label
>
<label for="title" class="block text-sm font-medium text-zinc-400">{{
$t("news.article.titles")
}}</label>
<input
id="title"
v-model="newArticle.title"
@ -34,8 +34,10 @@
</div>
<div>
<label for="excerpt" class="block text-sm font-medium text-zinc-400"
>Short description</label
<label
for="excerpt"
class="block text-sm font-medium text-zinc-400"
>{{ $t("news.article.shortDesc") }}</label
>
<input
id="excerpt"
@ -47,8 +49,10 @@
</div>
<div>
<label for="content" class="block text-sm font-medium text-zinc-400"
>Content (Markdown)</label
<label
for="content"
class="block text-sm font-medium text-zinc-400"
>{{ $t("news.article.content") }}</label
>
<div class="mt-1 flex flex-col gap-4">
<!-- Markdown shortcuts -->
@ -69,7 +73,9 @@
>
<!-- Editor -->
<div class="flex flex-col">
<span class="text-sm text-zinc-500 mb-2">Editor</span>
<span class="text-sm text-zinc-500 mb-2">{{
$t("news.article.editor")
}}</span>
<textarea
id="content"
ref="contentEditor"
@ -82,7 +88,9 @@
<!-- Preview -->
<div class="flex flex-col">
<span class="text-sm text-zinc-500 mb-2">Preview</span>
<span class="text-sm text-zinc-500 mb-2">{{
$t("news.article.preview")
}}</span>
<div
class="flex-1 p-4 rounded-md bg-zinc-900 border border-zinc-700 overflow-y-auto"
>
@ -95,8 +103,7 @@
</div>
</div>
<p class="mt-2 text-sm text-zinc-500">
Use the shortcuts above or write Markdown directly. Supports
**bold**, *italic*, [links](url), and more.
{{ $t("news.article.editorGuide") }}
</p>
</div>
@ -114,7 +121,7 @@
/>
<span
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
>Upload cover image</span
>{{ $t("news.article.uploadCover") }}</span
>
<p v-if="currentFile" class="mt-1 text-xs text-zinc-400">
{{ currentFile.name }}
@ -130,9 +137,9 @@
</div>
<div>
<label class="block text-sm font-medium text-zinc-400 mb-2"
>Tags</label
>
<label class="block text-sm font-medium text-zinc-400 mb-2">{{
$t("common.tags")
}}</label>
<div class="flex flex-wrap gap-2 mb-2">
<span
v-for="tag in newArticle.tags"
@ -153,7 +160,7 @@
<input
v-model="newTagInput"
type="text"
placeholder="Add a tag..."
:placeholder="$t('news.article.tagPlaceholder')"
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
@keydown.enter.prevent="addTag"
/>
@ -162,7 +169,7 @@
class="mt-1 px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
@click="addTag"
>
Add
{{ $t("news.article.add") }}
</button>
</div>
</div>
@ -188,13 +195,13 @@
class="bg-blue-600 text-white hover:bg-blue-500"
@click="() => createArticle()"
>
Submit
{{ $t("news.article.submit") }}
</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="() => (modalOpen = !modalOpen)"
>
Cancel
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
@ -236,18 +243,49 @@ const markdownPreview = computed(() => {
const file = ref<FileList | undefined>();
const currentFile = computed(() => file.value?.item(0));
const { t } = useI18n();
const error = ref<string | undefined>();
const contentEditor = ref<HTMLTextAreaElement>();
const markdownShortcuts = [
{ label: "Bold", prefix: "**", suffix: "**", placeholder: "bold text" },
{ label: "Italic", prefix: "_", suffix: "_", placeholder: "italic text" },
{ label: "Link", prefix: "[", suffix: "](url)", placeholder: "link text" },
{ label: "Code", prefix: "`", suffix: "`", placeholder: "code" },
{ label: "List Item", prefix: "- ", suffix: "", placeholder: "list item" },
{ label: "Heading", prefix: "## ", suffix: "", placeholder: "heading" },
{
label: t("editor.bold"),
prefix: "**",
suffix: "**",
placeholder: t("editor.boldPlaceholder"),
},
{
label: t("editor.italic"),
prefix: "_",
suffix: "_",
placeholder: t("editor.italicPlaceholder"),
},
{
label: t("editor.link"),
prefix: "[",
suffix: "](url)",
placeholder: t("editor.linkPlaceholder"),
},
{
label: t("editor.code"),
prefix: "`",
suffix: "`",
placeholder: t("editor.codePlaceholder"),
},
{
label: t("editor.listItem"),
prefix: "- ",
suffix: "",
placeholder: t("editor.listItemPlaceholder"),
},
{
label: t("editor.heading"),
prefix: "## ",
suffix: "",
placeholder: t("editor.headingPlaceholder"),
},
];
function handleContentKeydown(e: KeyboardEvent) {
@ -369,7 +407,7 @@ async function createArticle() {
modalOpen.value = false;
} catch (e) {
// @ts-expect-error attempt to get statusMessage on error
error.value = e?.statusMessage ?? "An unknown error occured.";
error.value = e?.statusMessage ?? t("errors.unknown");
} finally {
loading.value = false;
}

View File

@ -6,7 +6,7 @@
<!-- Search and filters -->
<div class="space-y-6">
<div>
<label for="search" class="sr-only">Search articles</label>
<label for="search" class="sr-only">{{ $t("news.search") }}</label>
<div class="relative">
<div
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
@ -21,31 +21,35 @@
v-model="searchQuery"
type="text"
class="block w-full rounded-md border-0 bg-zinc-800 py-2.5 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
placeholder="Search articles..."
:placeholder="$t('news.searchPlaceholder')"
/>
</div>
</div>
<div class="pt-2">
<label for="date" class="block text-sm font-medium text-zinc-400 mb-2"
>Date</label
<label
for="date"
class="block text-sm font-medium text-zinc-400 mb-2"
>{{ $t("common.date") }}</label
>
<select
id="date"
v-model="dateFilter"
class="mt-1 block w-full rounded-md border-0 bg-zinc-800 py-2 pl-3 pr-10 text-zinc-100 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
>
<option value="all">All time</option>
<option value="today">Today</option>
<option value="week">This week</option>
<option value="month">This month</option>
<option value="year">This year</option>
<option value="all">{{ $t("news.filter.all") }}</option>
<option value="today">{{ $t("common.today") }}</option>
<option value="week">{{ $t("news.filter.week") }}</option>
<option value="month">{{ $t("news.filter.month") }}</option>
<option value="year">{{ $t("news.filter.year") }}</option>
</select>
</div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-zinc-400 mb-2">Tags</label>
<label class="block text-sm font-medium text-zinc-400 mb-2">
{{ $t("common.tags") }}
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in availableTags"
@ -102,9 +106,9 @@
<div
class="relative mt-2 flex items-center gap-x-2 text-xs text-zinc-500"
>
<time :datetime="article.publishedAt">{{
formatDate(article.publishedAt)
}}</time>
<time :datetime="article.publishedAt">
{{ $d(new Date(article.publishedAt), "short") }}
</time>
</div>
</div>
</NuxtLink>
@ -146,14 +150,6 @@ const toggleTag = (tag: string) => {
}
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
const formatExcerpt = (excerpt: string) => {
// TODO: same as one in NewsArticleCreateButton
// Convert markdown to HTML

View File

@ -33,7 +33,7 @@
class="inline-flex rounded-md text-zinc-400 hover:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="() => deleteMe()"
>
<span class="sr-only">Close</span>
<span class="sr-only">{{ $t("close") }}</span>
<XMarkIcon class="size-5" aria-hidden="true" />
</button>
</div>

View File

@ -15,7 +15,7 @@
/>
<span class="ml-3 block truncate">{{ model }}</span>
</span>
<span v-else>Please select a platform...</span>
<span v-else>{{ $t("library.admin.import.selectPlatform") }}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2"
>

View File

@ -0,0 +1,32 @@
<template>
<div class="relative inline-block group">
<!-- 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: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,10 +1,12 @@
<template>
<div>
<label for="path" class="block text-sm font-medium leading-6 text-zinc-100"
>Path</label
<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">
An absolute path to your game library.
{{ $t("library.admin.sources.fsPathDesc") }}
</p>
<div class="mt-2">
<input
@ -13,7 +15,7 @@
name="path"
type="text"
autocomplete="path"
placeholder="/mnt/games"
: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>

View File

@ -49,7 +49,7 @@
/>
<span
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
>Upload file</span
>{{ $t("uploadFile") }}</span
>
<div v-if="currentFileList">
<p
@ -80,7 +80,7 @@
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
@click="() => uploadFile_wrapper()"
>
Upload
{{ $t("upload") }}
</LoadingButton>
<button
ref="cancelButtonRef"
@ -88,7 +88,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="open = false"
>
Cancel
{{ $t("cancel") }}
</button>
</div>
<div v-if="uploadError" class="mt-3 rounded-md bg-red-600/10 p-4">
@ -129,6 +129,7 @@ const open = defineModel<boolean>({
required: true,
});
const { t } = useI18n();
const file = ref<FileList | undefined>();
const currentFiles = computed(() => file.value);
const currentFileList = computed(() => {
@ -176,7 +177,7 @@ function uploadFile_wrapper() {
uploadLoading.value = true;
uploadFile()
.catch((error) => {
uploadError.value = error.statusMessage ?? "An unknown error occurred.";
uploadError.value = error.statusMessage ?? t("errors.unknown");
})
.finally(() => {
uploadLoading.value = false;

View File

@ -1,13 +1,12 @@
<template>
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
<h2 id="footer-heading" class="sr-only">Footer</h2>
<h2 id="footer-heading" class="sr-only">{{ $t("footer.footer") }}</h2>
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8">
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
<div class="space-y-8">
<DropWordmark class="h-10" />
<p class="text-sm leading-6 text-zinc-300">
An open-source game distribution platform built for speed,
flexibility and beauty.
{{ $t("drop.desc") }}
</p>
<div class="flex space-x-6">
<NuxtLink
@ -25,7 +24,9 @@
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
<div class="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 class="text-sm font-semibold leading-6 text-white">Games</h3>
<h3 class="text-sm font-semibold leading-6 text-white">
{{ $t("footer.games") }}
</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.games" :key="item.name">
<NuxtLink
@ -38,7 +39,7 @@
</div>
<div class="mt-10 md:mt-0">
<h3 class="text-sm font-semibold leading-6 text-white">
Community
{{ $t("userHeader.links.community") }}
</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.community" :key="item.name">
@ -54,7 +55,7 @@
<div class="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 class="text-sm font-semibold leading-6 text-white">
Documentation
{{ $t("footer.documentation") }}
</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.documentation" :key="item.name">
@ -67,7 +68,9 @@
</ul>
</div>
<div class="mt-10 md:mt-0">
<h3 class="text-sm font-semibold leading-6 text-white">About</h3>
<h3 class="text-sm font-semibold leading-6 text-white">
{{ $t("footer.about") }}
</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.about" :key="item.name">
<NuxtLink
@ -87,43 +90,44 @@
<script setup lang="ts">
import { IconsDiscordLogo, IconsGithubLogo } from "#components";
const { t } = useI18n();
const navigation = {
games: [
{ name: "Newly Added", href: "#" },
{ name: "New Releases", href: "#" },
{ name: "Top Sellers", href: "#" },
{ name: "Find a Game", href: "#" },
{ name: t("store.recentlyAdded"), href: "#" },
{ name: t("store.recentlyReleased"), href: "#" },
{ name: t("footer.topSellers"), href: "#" },
{ name: t("footer.findGame"), href: "#" },
],
community: [
{ name: "Friends", href: "#" },
{ name: "Groups", href: "#" },
{ name: "Servers", href: "#" },
{ name: t("common.friends"), href: "#" },
{ name: t("common.groups"), href: "#" },
{ name: t("common.servers"), href: "#" },
],
documentation: [
{ name: "API", href: "https://api.droposs.org/" },
{ name: t("footer.api"), href: "https://api.droposs.org/" },
{
name: "Server Docs",
name: t("footer.docs.server"),
href: "https://wiki.droposs.org/guides/quickstart.html",
},
{
name: "Client Docs",
name: t("footer.docs.client"),
href: "https://wiki.droposs.org/guides/client.html",
},
],
about: [
{ name: "About Drop", href: "https://droposs.org/" },
{ name: "Features", href: "https://droposs.org/features" },
{ name: "FAQ", href: "https://droposs.org/faq" },
{ name: t("footer.aboutDrop"), href: "https://droposs.org/" },
{ name: t("footer.features"), href: "https://droposs.org/features" },
{ name: t("footer.faq"), href: "https://droposs.org/faq" },
],
social: [
{
name: "GitHub",
name: t("footer.social.github"),
href: "https://github.com/Drop-OSS",
icon: IconsGithubLogo,
},
{
name: "Discord",
name: t("footer.social.discord"),
href: "https://discord.gg/NHx46XKJWA",
icon: IconsDiscordLogo,
},

View File

@ -54,6 +54,7 @@
</transition>
</Menu>
</li>
<UserHeaderSelectLang />
<UserHeaderUserWidget />
</ol>
</div>
@ -76,7 +77,7 @@
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
@click="sidebarOpen = true"
>
<span class="sr-only">Open sidebar</span>
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
</button>
</div>
@ -125,7 +126,9 @@
class="-m-2.5 p-2.5"
@click="sidebarOpen = false"
>
<span class="sr-only">Close sidebar</span>
<span class="sr-only">{{
$t("userHeader.closeSidebar")
}}</span>
<XMarkIcon class="h-6 w-6 text-zinc-400" aria-hidden="true" />
</button>
</div>
@ -172,6 +175,11 @@
<BellIcon class="h-5" />
</UserHeaderWidget>
</li>
<li class="w-full">
<UserHeaderWidget class="w-full">
<UserHeaderSelectLang />
</UserHeaderWidget>
</li>
</div>
</nav>
</div>
@ -198,28 +206,29 @@ import { Bars3Icon } from "@heroicons/vue/24/outline";
import { XMarkIcon } from "@heroicons/vue/24/solid";
const router = useRouter();
const { t } = useI18n();
const homepageURL = "/store";
const navigation: Array<NavigationItem> = [
{
prefix: "/store",
route: "/store",
label: "Store",
label: t("store.title"),
},
{
prefix: "/library",
route: "/library",
label: "Library",
label: t("userHeader.links.library"),
},
{
prefix: "/community",
route: "/community",
label: "Community",
label: t("userHeader.links.community"),
},
{
prefix: "/news",
route: "/news",
label: "News",
label: t("userHeader.links.news"),
},
];

View File

@ -6,7 +6,7 @@
>
<div class="ml-4 mt-2">
<h3 class="text-base font-semibold text-zinc-100 text-sm">
Unread notifications
{{ $t("account.notifications.unread") }}
</h3>
</div>
<div class="ml-4 mt-2 shrink-0">
@ -15,7 +15,15 @@
type="button"
class="text-sm text-zinc-400"
>
View all &rarr;
<i18n-t
keypath="account.notifications.all"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
@ -32,7 +40,7 @@
v-if="props.notifications.length == 0"
class="text-sm text-zinc-400 p-3 text-center w-full"
>
No notifications
{{ $t("account.notifications.none") }}
</div>
</PanelWidget>
</template>

View File

@ -0,0 +1,78 @@
<script setup lang="ts">
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/vue";
import { ChevronDownIcon } from "@heroicons/vue/16/solid";
const { locales, locale: currLocale, setLocale } = useI18n();
function localToEmoji(local: string): string {
switch (local) {
case "en":
case "en-gb":
case "en-ca":
case "en-au":
case "en-us": {
return "🇺🇸";
}
case "en-pirate": {
return "🏴‍☠️";
}
default: {
return "❓";
}
}
}
</script>
<template>
<Menu as="div" class="relative inline-block">
<MenuButton>
<UserHeaderWidget>
<div
class="inline-flex items-center text-zinc-300 hover:text-white h-5"
>
<EmojiText :emoji="localToEmoji(currLocale)" />
<!-- <span class="ml-2 text-sm font-bold">{{ locale }}</span> -->
<ChevronDownIcon class="ml-3 h-4" />
</div>
</UserHeaderWidget>
</MenuButton>
<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 top-10 z-50 w-56 origin-top-right focus:outline-none shadow-md"
>
<PanelWidget class="flex-col gap-y-2">
<div class="flex flex-col">
<MenuItem
v-for="locale in locales"
:key="locale.code"
hydrate-on-visible
as="div"
>
<button @click="setLocale(locale.code)">
<EmojiText :emoji="localToEmoji(locale.code)" />
{{ locale.name }}
</button>
</MenuItem>
</div>
</PanelWidget>
</MenuItems>
</transition>
</Menu>
</template>
<style scoped>
img.emoji {
height: 1em;
width: 1em;
margin: 0 0.05em 0 0.1em;
vertical-align: -0.1em;
}
</style>

View File

@ -46,29 +46,28 @@
hydrate-on-visible
as="div"
>
<!-- TODO: think this would work better as a NuxtLink instead of a button -->
<button
:href="nav.route"
<NuxtLink
:to="nav.route"
:class="[
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
'w-full text-left transition block px-4 py-2 text-sm',
]"
@click="() => navigateTo(nav.route, close)"
@click="close"
>
{{ nav.label }}
</button>
</NuxtLink>
</MenuItem>
<MenuItem v-slot="{ active }" hydrate-on-visible as="div">
<!-- TODO: think this would work better as a NuxtLink instead of a button -->
<a
<MenuItem v-slot="{ active, close }" hydrate-on-visible as="div">
<NuxtLink
to="/auth/signout"
:class="[
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
'w-full text-left transition block px-4 py-2 text-sm',
]"
href="/auth/signout"
@click="close"
>
Signout
</a>
{{ $t("auth.signout") }}
</NuxtLink>
</MenuItem>
</div>
</PanelWidget>
@ -84,17 +83,18 @@ import { useObject } from "~/composables/objects";
import type { NavigationItem } from "~/composables/types";
const user = useUser();
const { t } = useI18n();
const navigation: NavigationItem[] = [
user.value?.admin
? {
label: "Admin Dashboard",
label: t("userHeader.profile.admin"),
route: "/admin",
prefix: "",
}
: undefined,
{
label: "Account settings",
label: t("userHeader.profile.settings"),
route: "/account",
prefix: "",
},