mirror of
https://github.com/gregor-tokarev/cubicdone.git
synced 2026-09-12 19:51:06 +05:00
Run prettier && change cors
This commit is contained in:
parent
78c59e0d4c
commit
f1af725349
@ -5,7 +5,6 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
|
||||
"packageManager": "npm@9.5.1",
|
||||
"dependencies": {
|
||||
"@sentry/vite-plugin": "^2.21.1",
|
||||
|
||||
@ -3,32 +3,37 @@ import { ApiKey } from "contract-models";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
defineProps<{
|
||||
apiKey: ApiKey;
|
||||
apiKey: ApiKey;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "revoke", value: void): void;
|
||||
(e: "revoke", value: void): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
revoke: "Revoke"
|
||||
},
|
||||
ru: {
|
||||
revoke: "Отозвать"
|
||||
}
|
||||
}
|
||||
})
|
||||
messages: {
|
||||
en: {
|
||||
revoke: "Revoke",
|
||||
},
|
||||
ru: {
|
||||
revoke: "Отозвать",
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between rounded bg-gray-100 px-2 py-1 text-black">
|
||||
<span>{{ apiKey.label }}</span>
|
||||
<button class="border-gray-150 rounded border px-2 py-[3px] text-xs" @click="emit('revoke')">
|
||||
{{ t("revoke") }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between rounded bg-gray-100 px-2 py-1 text-black"
|
||||
>
|
||||
<span>{{ apiKey.label }}</span>
|
||||
<button
|
||||
class="border-gray-150 rounded border px-2 py-[3px] text-xs"
|
||||
@click="emit('revoke')"
|
||||
>
|
||||
{{ t("revoke") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@ -13,33 +13,33 @@ import { i18n, trpc } from "../main.ts";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
ru: {
|
||||
plan: "План",
|
||||
inbox: "Входящие",
|
||||
projects: "Проекты",
|
||||
integrations: "Интеграции",
|
||||
logout: "Выйти",
|
||||
profile: "Профиль",
|
||||
syncing: "Синхронизация",
|
||||
messages: {
|
||||
ru: {
|
||||
plan: "План",
|
||||
inbox: "Входящие",
|
||||
projects: "Проекты",
|
||||
integrations: "Интеграции",
|
||||
logout: "Выйти",
|
||||
profile: "Профиль",
|
||||
syncing: "Синхронизация",
|
||||
|
||||
syncNote: `Ваши {syncCount} несохраненные изменения будут загружены когда вы вернетесь в онлайн
|
||||
syncNote: `Ваши {syncCount} несохраненные изменения будут загружены когда вы вернетесь в онлайн
|
||||
<br />
|
||||
<br />
|
||||
<span class="text-gray-300">Кстати:</span> они перезапушут ваши изменения с других устройств
|
||||
`,
|
||||
|
||||
chooseLang: "Выбрать язык",
|
||||
},
|
||||
en: {
|
||||
plan: "Plan",
|
||||
inbox: "Inbox",
|
||||
projects: "Projects",
|
||||
integrations: "Integrations",
|
||||
logout: "Logout",
|
||||
profile: "Open profile",
|
||||
syncing: "Syncing",
|
||||
syncNote: `Your {syncCount} unsaved changes will be loaded when you regain
|
||||
chooseLang: "Выбрать язык",
|
||||
},
|
||||
en: {
|
||||
plan: "Plan",
|
||||
inbox: "Inbox",
|
||||
projects: "Projects",
|
||||
integrations: "Integrations",
|
||||
logout: "Logout",
|
||||
profile: "Open profile",
|
||||
syncing: "Syncing",
|
||||
syncNote: `Your {syncCount} unsaved changes will be loaded when you regain
|
||||
connectivity
|
||||
<br />
|
||||
<br />
|
||||
@ -48,35 +48,35 @@ const { t } = useI18n({
|
||||
|
||||
`,
|
||||
|
||||
chooseLang: "Choose language",
|
||||
},
|
||||
chooseLang: "Choose language",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const navItems = ref([
|
||||
{
|
||||
title: t("plan"),
|
||||
icon: "plan",
|
||||
link: "/",
|
||||
hint: "⌘ + G",
|
||||
},
|
||||
{
|
||||
title: t("inbox"),
|
||||
icon: "inbox",
|
||||
link: "/inbox",
|
||||
hint: "⌘ + I",
|
||||
},
|
||||
{
|
||||
title: t("projects"),
|
||||
icon: "folder",
|
||||
link: "/projects",
|
||||
hint: "⌘ + P",
|
||||
},
|
||||
{
|
||||
title: t("integrations"),
|
||||
icon: "integrations",
|
||||
link: "/integrations",
|
||||
},
|
||||
{
|
||||
title: t("plan"),
|
||||
icon: "plan",
|
||||
link: "/",
|
||||
hint: "⌘ + G",
|
||||
},
|
||||
{
|
||||
title: t("inbox"),
|
||||
icon: "inbox",
|
||||
link: "/inbox",
|
||||
hint: "⌘ + I",
|
||||
},
|
||||
{
|
||||
title: t("projects"),
|
||||
icon: "folder",
|
||||
link: "/projects",
|
||||
hint: "⌘ + P",
|
||||
},
|
||||
{
|
||||
title: t("integrations"),
|
||||
icon: "integrations",
|
||||
link: "/integrations",
|
||||
},
|
||||
]);
|
||||
|
||||
const userStore = useUserStore();
|
||||
@ -84,19 +84,19 @@ const breakpoints = useBreakpoints(breakpointsTailwind);
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(() => {
|
||||
hotkeys("[", onBracket);
|
||||
hotkeys("[", onBracket);
|
||||
|
||||
window.addEventListener("resize", (_) => {
|
||||
compact.value = breakpoints.isSmaller("xl");
|
||||
});
|
||||
window.addEventListener("resize", (_) => {
|
||||
compact.value = breakpoints.isSmaller("xl");
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
hotkeys.unbind("[", onBracket);
|
||||
hotkeys.unbind("[", onBracket);
|
||||
});
|
||||
|
||||
function onBracket() {
|
||||
compact.value = !compact.value;
|
||||
compact.value = !compact.value;
|
||||
}
|
||||
|
||||
const compact = ref(breakpoints.isSmaller("xl"));
|
||||
@ -105,49 +105,49 @@ const openPanel = ref(false);
|
||||
const panelEl = ref<HTMLElement | null>(null);
|
||||
const userEl = ref<HTMLElement | null>(null);
|
||||
onClickOutside(
|
||||
panelEl,
|
||||
() => {
|
||||
openPanel.value = false;
|
||||
},
|
||||
{ ignore: [userEl] },
|
||||
panelEl,
|
||||
() => {
|
||||
openPanel.value = false;
|
||||
},
|
||||
{ ignore: [userEl] },
|
||||
);
|
||||
|
||||
async function gotoProfile() {
|
||||
await router.push("/profile");
|
||||
openPanel.value = false;
|
||||
await router.push("/profile");
|
||||
openPanel.value = false;
|
||||
}
|
||||
|
||||
function deleteCookie(name: string) {
|
||||
document.cookie = name + "=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/";
|
||||
document.cookie = name + "=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/";
|
||||
}
|
||||
|
||||
async function onSignOut() {
|
||||
await trpc.auth.logout.mutate();
|
||||
await trpc.auth.logout.mutate();
|
||||
|
||||
deleteCookie("session");
|
||||
location.href = "https://cubicdone.com"
|
||||
deleteCookie("session");
|
||||
location.href = "https://cubicdone.com";
|
||||
}
|
||||
|
||||
const { syncCount, networkState } = useSyncState();
|
||||
|
||||
const offlineBadge = ref<HTMLElement | null>(null);
|
||||
const offlineBadgeBound = computed(() => {
|
||||
if (!offlineBadge.value) return;
|
||||
return offlineBadge.value.getBoundingClientRect();
|
||||
if (!offlineBadge.value) return;
|
||||
return offlineBadge.value.getBoundingClientRect();
|
||||
});
|
||||
|
||||
const offlineHovered = ref(false);
|
||||
function onChangeOfflineHovered(value: boolean) {
|
||||
if (!syncing.value) {
|
||||
offlineHovered.value = value;
|
||||
}
|
||||
if (!syncing.value) {
|
||||
offlineHovered.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
const syncing = computed(() => {
|
||||
return networkState.value === "online" && syncCount.value > 0;
|
||||
return networkState.value === "online" && syncCount.value > 0;
|
||||
});
|
||||
const showBadge = computed(() => {
|
||||
return networkState.value === "offline" || syncing.value;
|
||||
return networkState.value === "offline" || syncing.value;
|
||||
});
|
||||
|
||||
// locale modal
|
||||
@ -155,124 +155,180 @@ const localeQuery = ref("");
|
||||
const locale = useLocalStorage("chosen_locale", i18n.global.locale.value);
|
||||
|
||||
const localeOptions = [
|
||||
{ text: "Русский", id: "ru", icon: "🇷🇺" },
|
||||
{ text: "English", id: "en", icon: "🇺🇸" },
|
||||
{ text: "Русский", id: "ru", icon: "🇷🇺" },
|
||||
{ text: "English", id: "en", icon: "🇺🇸" },
|
||||
];
|
||||
const currentLocale = computed(() => {
|
||||
return localeOptions.find((l) => l.id === locale.value);
|
||||
return localeOptions.find((l) => l.id === locale.value);
|
||||
});
|
||||
|
||||
const localeOpen = ref(false);
|
||||
|
||||
const fuse = new Fuse(localeOptions, { keys: ["text", "id"] });
|
||||
const filteredOptions = computed(() => {
|
||||
return localeQuery.value
|
||||
? fuse.search(localeQuery.value).map((r) => r.item)
|
||||
: localeOptions;
|
||||
return localeQuery.value
|
||||
? fuse.search(localeQuery.value).map((r) => r.item)
|
||||
: localeOptions;
|
||||
});
|
||||
|
||||
const checkedIndex = computed(() => {
|
||||
return localeOptions.findIndex((l) => l.id === locale.value);
|
||||
return localeOptions.findIndex((l) => l.id === locale.value);
|
||||
});
|
||||
|
||||
function onLocaleSelect(id: (typeof localeOptions)[0]["id"]) {
|
||||
locale.value = id;
|
||||
location.reload(); // app will take locale on boot
|
||||
locale.value = id;
|
||||
location.reload(); // app will take locale on boot
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-[250px] flex-col rounded-br-2xl rounded-tr-2xl bg-black px-[18px] py-4"
|
||||
:class="{ '!w-[60px] !px-[13px]': compact }">
|
||||
<!-- title block-->
|
||||
<div class="relative flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1.5" :class="{
|
||||
'max-w-[85%]': !compact,
|
||||
'w-full': compact,
|
||||
}">
|
||||
<div v-if="userStore.user" ref="userEl" @click="openPanel = !openPanel"
|
||||
class="!hover:text-white flex max-w-full cursor-pointer items-center from-[#1A1A1A] to-[#141414] transition-colors hover:bg-gradient-to-r"
|
||||
:class="{
|
||||
'space-x-2 px-[1px] py-0.5': !compact,
|
||||
}">
|
||||
<img :src="userStore.user?.avatar ?? ''" :alt="userStore?.user?.firstName ?? 'profile image'"
|
||||
class="!min-h-[34px] !w-[34px] !min-w-[34px] shrink-0 overflow-hidden rounded-full" />
|
||||
<p v-if="!compact && !showBadge"
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-gray-200">
|
||||
{{ userStore.user.firstName }} {{ userStore.user.lastName }}
|
||||
</p>
|
||||
</div>
|
||||
<div ref="offlineBadge" v-if="!compact && showBadge"
|
||||
class="flex items-center space-x-1.5 rounded-md bg-gray-900 px-2 py-1.5 text-[12px] text-gray-500"
|
||||
@mouseenter="onChangeOfflineHovered(true)" @mouseleave="onChangeOfflineHovered(false)">
|
||||
<template v-if="!syncing">
|
||||
<Icon name="offline"></Icon>
|
||||
<span>offline {{ syncCount }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Icon name="sync" class="animate-spin text-gray-300"></Icon>
|
||||
<span class="text-gray-300">{{ t("syncing") }}...</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<Icon v-if="!compact" v-hint="'['" name="sidebar-left" class="cursor-pointer text-gray-200"
|
||||
@click="compact = !compact">
|
||||
</Icon>
|
||||
<div ref="panelEl"
|
||||
class="absolute bottom-0 left-0 w-[200px] translate-y-[calc(100%+4px)] space-y-2 rounded-md border border-gray-900 bg-gradient-to-r from-[#1A1A1A] to-[#141414] px-2 py-1.5 z-10"
|
||||
v-if="openPanel">
|
||||
<button @click="gotoProfile"
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded px-2 py-1.5 text-[14px] text-gray-200 transition-colors hover:bg-black hover:text-gray-50">
|
||||
<span class="">{{ t("profile") }}</span>
|
||||
<span>⌘ + O</span>
|
||||
</button>
|
||||
<button @click="onSignOut"
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded px-2 py-1.5 text-[14px] text-red-400 transition-colors hover:bg-black">
|
||||
<span class="">{{ t("logout") }}</span>
|
||||
<Icon name="exit" class="!h-4 !w-4"></Icon>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-[250px] flex-col rounded-br-2xl rounded-tr-2xl bg-black px-[18px] py-4"
|
||||
:class="{ '!w-[60px] !px-[13px]': compact }"
|
||||
>
|
||||
<!-- title block-->
|
||||
<div class="relative flex items-center justify-between">
|
||||
<div
|
||||
class="flex items-center space-x-1.5"
|
||||
:class="{
|
||||
'max-w-[85%]': !compact,
|
||||
'w-full': compact,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="userStore.user"
|
||||
ref="userEl"
|
||||
@click="openPanel = !openPanel"
|
||||
class="!hover:text-white flex max-w-full cursor-pointer items-center from-[#1A1A1A] to-[#141414] transition-colors hover:bg-gradient-to-r"
|
||||
:class="{
|
||||
'space-x-2 px-[1px] py-0.5': !compact,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
:src="userStore.user?.avatar ?? ''"
|
||||
:alt="userStore?.user?.firstName ?? 'profile image'"
|
||||
class="!min-h-[34px] !w-[34px] !min-w-[34px] shrink-0 overflow-hidden rounded-full"
|
||||
/>
|
||||
<p
|
||||
v-if="!compact && !showBadge"
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-gray-200"
|
||||
>
|
||||
{{ userStore.user.firstName }} {{ userStore.user.lastName }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="compact && showBadge" ref="offlineBadge" @mouseenter="onChangeOfflineHovered(true)"
|
||||
@mouseleave="onChangeOfflineHovered(false)"
|
||||
class="mt-3 flex h-[32px] w-[32px] items-center justify-center rounded bg-gray-900 text-gray-500">
|
||||
<Icon v-if="!syncing" name="offline"></Icon>
|
||||
<Icon v-else name="sync" class="animate-spin text-gray-300"></Icon>
|
||||
<div
|
||||
ref="offlineBadge"
|
||||
v-if="!compact && showBadge"
|
||||
class="flex items-center space-x-1.5 rounded-md bg-gray-900 px-2 py-1.5 text-[12px] text-gray-500"
|
||||
@mouseenter="onChangeOfflineHovered(true)"
|
||||
@mouseleave="onChangeOfflineHovered(false)"
|
||||
>
|
||||
<template v-if="!syncing">
|
||||
<Icon name="offline"></Icon>
|
||||
<span>offline {{ syncCount }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Icon name="sync" class="animate-spin text-gray-300"></Icon>
|
||||
<span class="text-gray-300">{{ t("syncing") }}...</span>
|
||||
</template>
|
||||
</div>
|
||||
<!-- navigation block-->
|
||||
<nav class="mt-6 space-y-1.5">
|
||||
<router-link class="flex cursor-pointer items-center space-x-2 rounded px-1.5 py-1 text-base text-gray-200"
|
||||
:class="{
|
||||
'h-unset w-full': !compact,
|
||||
'h-[32px] w-[32px] justify-center': compact,
|
||||
}" v-for="item in navItems" :to="item.link" v-hint="item.hint" :aria-label="item.title"
|
||||
exact-active-class="!text-white bg-gradient-to-r from-[#1A1A1A] to-[#141414]">
|
||||
<Icon :size="18" :name="item.icon"></Icon>
|
||||
<span v-if="!compact">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="mt-auto flex cursor-pointer items-center space-x-2 rounded from-[#1A1A1A] to-[#141414] px-1.5 py-2 text-base text-gray-200 transition-colors hover:bg-gradient-to-r hover:text-white"
|
||||
:class="{
|
||||
'h-unset w-full': !compact,
|
||||
'h-[32px] w-[32px] justify-center': compact,
|
||||
}" @click="localeOpen = !localeOpen">
|
||||
<span>{{ currentLocale?.icon }}</span>
|
||||
<span v-if="!compact">{{ currentLocale?.text }}</span>
|
||||
</div>
|
||||
<teleport to="body">
|
||||
<div class="absolute pt-1" @mouseenter="offlineHovered = true" @mouseleave="offlineHovered = false"
|
||||
v-if="offlineBadgeBound && offlineHovered" :style="{
|
||||
top: `${offlineBadgeBound.top + offlineBadgeBound.height}px`,
|
||||
left: `${offlineBadgeBound.left}px`,
|
||||
}">
|
||||
<p class="w-[215px] rounded-md bg-gray-900 px-2.5 pb-4 pt-2 text-[12px] text-gray-500"
|
||||
v-html="t('syncNote', { syncCount: syncCount })"></p>
|
||||
</div>
|
||||
</teleport>
|
||||
<SelectModal v-if="filteredOptions" :hint-text="t('chooseLang')" :checked-index="checkedIndex"
|
||||
:options="filteredOptions" v-model:open="localeOpen" v-model:query="localeQuery" @submit="onLocaleSelect">
|
||||
</SelectModal>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="!compact"
|
||||
v-hint="'['"
|
||||
name="sidebar-left"
|
||||
class="cursor-pointer text-gray-200"
|
||||
@click="compact = !compact"
|
||||
>
|
||||
</Icon>
|
||||
<div
|
||||
ref="panelEl"
|
||||
class="absolute bottom-0 left-0 z-10 w-[200px] translate-y-[calc(100%+4px)] space-y-2 rounded-md border border-gray-900 bg-gradient-to-r from-[#1A1A1A] to-[#141414] px-2 py-1.5"
|
||||
v-if="openPanel"
|
||||
>
|
||||
<button
|
||||
@click="gotoProfile"
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded px-2 py-1.5 text-[14px] text-gray-200 transition-colors hover:bg-black hover:text-gray-50"
|
||||
>
|
||||
<span class="">{{ t("profile") }}</span>
|
||||
<span>⌘ + O</span>
|
||||
</button>
|
||||
<button
|
||||
@click="onSignOut"
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded px-2 py-1.5 text-[14px] text-red-400 transition-colors hover:bg-black"
|
||||
>
|
||||
<span class="">{{ t("logout") }}</span>
|
||||
<Icon name="exit" class="!h-4 !w-4"></Icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="compact && showBadge"
|
||||
ref="offlineBadge"
|
||||
@mouseenter="onChangeOfflineHovered(true)"
|
||||
@mouseleave="onChangeOfflineHovered(false)"
|
||||
class="mt-3 flex h-[32px] w-[32px] items-center justify-center rounded bg-gray-900 text-gray-500"
|
||||
>
|
||||
<Icon v-if="!syncing" name="offline"></Icon>
|
||||
<Icon v-else name="sync" class="animate-spin text-gray-300"></Icon>
|
||||
</div>
|
||||
<!-- navigation block-->
|
||||
<nav class="mt-6 space-y-1.5">
|
||||
<router-link
|
||||
class="flex cursor-pointer items-center space-x-2 rounded px-1.5 py-1 text-base text-gray-200"
|
||||
:class="{
|
||||
'h-unset w-full': !compact,
|
||||
'h-[32px] w-[32px] justify-center': compact,
|
||||
}"
|
||||
v-for="item in navItems"
|
||||
:to="item.link"
|
||||
v-hint="item.hint"
|
||||
:aria-label="item.title"
|
||||
exact-active-class="!text-white bg-gradient-to-r from-[#1A1A1A] to-[#141414]"
|
||||
>
|
||||
<Icon :size="18" :name="item.icon"></Icon>
|
||||
<span v-if="!compact">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<div
|
||||
class="mt-auto flex cursor-pointer items-center space-x-2 rounded from-[#1A1A1A] to-[#141414] px-1.5 py-2 text-base text-gray-200 transition-colors hover:bg-gradient-to-r hover:text-white"
|
||||
:class="{
|
||||
'h-unset w-full': !compact,
|
||||
'h-[32px] w-[32px] justify-center': compact,
|
||||
}"
|
||||
@click="localeOpen = !localeOpen"
|
||||
>
|
||||
<span>{{ currentLocale?.icon }}</span>
|
||||
<span v-if="!compact">{{ currentLocale?.text }}</span>
|
||||
</div>
|
||||
<teleport to="body">
|
||||
<div
|
||||
class="absolute pt-1"
|
||||
@mouseenter="offlineHovered = true"
|
||||
@mouseleave="offlineHovered = false"
|
||||
v-if="offlineBadgeBound && offlineHovered"
|
||||
:style="{
|
||||
top: `${offlineBadgeBound.top + offlineBadgeBound.height}px`,
|
||||
left: `${offlineBadgeBound.left}px`,
|
||||
}"
|
||||
>
|
||||
<p
|
||||
class="w-[215px] rounded-md bg-gray-900 px-2.5 pb-4 pt-2 text-[12px] text-gray-500"
|
||||
v-html="t('syncNote', { syncCount: syncCount })"
|
||||
></p>
|
||||
</div>
|
||||
</teleport>
|
||||
<SelectModal
|
||||
v-if="filteredOptions"
|
||||
:hint-text="t('chooseLang')"
|
||||
:checked-index="checkedIndex"
|
||||
:options="filteredOptions"
|
||||
v-model:open="localeOpen"
|
||||
v-model:query="localeQuery"
|
||||
@submit="onLocaleSelect"
|
||||
>
|
||||
</SelectModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@ -5,38 +5,49 @@ import ProjectTag from "../UI/ProjectTag.vue";
|
||||
import Markdown from "@components/Markdown.vue";
|
||||
|
||||
defineProps<{
|
||||
task: Task;
|
||||
task: Task;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:status", value: Task["status"]): void;
|
||||
(e: "update:status", value: Task["status"]): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex cursor-grab items-start space-x-2 rounded-lg bg-gray-100 px-4 py-2.5 active:cursor-grabbing transition-colors hover:bg-gray-50"
|
||||
:class="{ 'opacity-50 hover:!bg-gray-100': task.status === 'done' }">
|
||||
<div class="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center break-words rounded-full border-2 border-gray-600 text-white"
|
||||
@click="emit('update:status', task.status === 'done' ? 'todo' : 'done')"
|
||||
:class="{ '!border-black bg-black': task.status === 'done' }">
|
||||
<Icon v-if="task.status === 'done'" name="double-check"></Icon>
|
||||
</div>
|
||||
<div class="flex flex-col items-start space-y-1.5">
|
||||
<Markdown :model-value="task.title"></Markdown>
|
||||
<div class="flex space-x-2">
|
||||
<ProjectTag v-if="task.projectId" :project-id="task.projectId"></ProjectTag>
|
||||
<a v-if="task.external" class="flex items-center space-x-1 rounded-lg bg-gray-400 px-1.5 py-1"
|
||||
:href="task.external.link" target="_blank">
|
||||
<img class="h-[14px] w-[14px]" :src="task.external.iconURL" alt="" />
|
||||
<span class="text-xs">{{
|
||||
task.external.projectTitle
|
||||
? task.external.projectTitle
|
||||
: task.external.integrationName
|
||||
}}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-grab items-start space-x-2 rounded-lg bg-gray-100 px-4 py-2.5 transition-colors hover:bg-gray-50 active:cursor-grabbing"
|
||||
:class="{ 'opacity-50 hover:!bg-gray-100': task.status === 'done' }"
|
||||
>
|
||||
<div
|
||||
class="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center break-words rounded-full border-2 border-gray-600 text-white"
|
||||
@click="emit('update:status', task.status === 'done' ? 'todo' : 'done')"
|
||||
:class="{ '!border-black bg-black': task.status === 'done' }"
|
||||
>
|
||||
<Icon v-if="task.status === 'done'" name="double-check"></Icon>
|
||||
</div>
|
||||
<div class="flex flex-col items-start space-y-1.5">
|
||||
<Markdown :model-value="task.title"></Markdown>
|
||||
<div class="flex space-x-2">
|
||||
<ProjectTag
|
||||
v-if="task.projectId"
|
||||
:project-id="task.projectId"
|
||||
></ProjectTag>
|
||||
<a
|
||||
v-if="task.external"
|
||||
class="flex items-center space-x-1 rounded-lg bg-gray-400 px-1.5 py-1"
|
||||
:href="task.external.link"
|
||||
target="_blank"
|
||||
>
|
||||
<img class="h-[14px] w-[14px]" :src="task.external.iconURL" alt="" />
|
||||
<span class="text-xs">{{
|
||||
task.external.projectTitle
|
||||
? task.external.projectTitle
|
||||
: task.external.integrationName
|
||||
}}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@ -23,113 +23,113 @@ import "./style.css";
|
||||
const pinia = createPinia();
|
||||
|
||||
export const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: import.meta.env.PROD
|
||||
? import.meta.env.VITE_SYNC_URL
|
||||
: "http://localhost:4000",
|
||||
fetch(url, options) {
|
||||
const cookies = cookie.parse(document.cookie);
|
||||
const sessionToken = cookies["session"];
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: import.meta.env.PROD
|
||||
? import.meta.env.VITE_SYNC_URL
|
||||
: "http://localhost:4000",
|
||||
fetch(url, options) {
|
||||
const cookies = cookie.parse(document.cookie);
|
||||
const sessionToken = cookies["session"];
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Authorization: sessionToken,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Authorization: sessionToken,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
Sentry.init({
|
||||
app,
|
||||
dsn: "https://b2a7371c480bdfb4603b8e8189a46d35@o1186023.ingest.us.sentry.io/4507594362585088",
|
||||
integrations: [
|
||||
Sentry.feedbackIntegration({
|
||||
colorScheme: "system",
|
||||
isEmailRequired: true,
|
||||
}),
|
||||
Sentry.replayIntegration(),
|
||||
Sentry.browserTracingIntegration(),
|
||||
],
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: 1.0, // Capture 100% of the transactions
|
||||
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
|
||||
tracePropagationTargets: ["localhost", /^https:\/\/api.cubicdone\.com/],
|
||||
// Session Replay
|
||||
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
|
||||
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
|
||||
app,
|
||||
dsn: "https://b2a7371c480bdfb4603b8e8189a46d35@o1186023.ingest.us.sentry.io/4507594362585088",
|
||||
integrations: [
|
||||
Sentry.feedbackIntegration({
|
||||
colorScheme: "system",
|
||||
isEmailRequired: true,
|
||||
}),
|
||||
Sentry.replayIntegration(),
|
||||
Sentry.browserTracingIntegration(),
|
||||
],
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: 1.0, // Capture 100% of the transactions
|
||||
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
|
||||
tracePropagationTargets: ["localhost", /^https:\/\/api.cubicdone\.com/],
|
||||
// Session Replay
|
||||
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
|
||||
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
|
||||
});
|
||||
|
||||
app
|
||||
.directive("hint", hint)
|
||||
.use(pinia)
|
||||
.use(vueSyncClientPlugin, {
|
||||
dbVersion: 11,
|
||||
schema: [
|
||||
taskStore,
|
||||
apiKeyStore,
|
||||
draftStore,
|
||||
projectStore,
|
||||
projectStatusStore,
|
||||
],
|
||||
onSync: async (sync, resolveFn) => {
|
||||
if (sync.targetTable === draftStore.name) {
|
||||
if (sync.action.actionName === "create")
|
||||
await trpc.draft.create.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "update")
|
||||
await trpc.draft.update.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "delete")
|
||||
await trpc.draft.delete.mutate(sync.action.id as string);
|
||||
} else if (sync.targetTable === taskStore.name) {
|
||||
if (sync.action.actionName === "create")
|
||||
await trpc.task.create.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "update")
|
||||
await trpc.task.update.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "delete")
|
||||
await trpc.task.delete.mutate(sync.action.id as string);
|
||||
} else if (sync.targetTable === apiKeyStore.name) {
|
||||
if (sync.action.actionName === "create")
|
||||
await trpc.apiKey.create.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "update")
|
||||
await trpc.apiKey.update.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "delete")
|
||||
await trpc.apiKey.delete.mutate(sync.action.id as string);
|
||||
} else if (sync.targetTable === projectStore.name) {
|
||||
if (sync.action.actionName === "create")
|
||||
await trpc.project.create.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "update")
|
||||
await trpc.project.update.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "delete")
|
||||
await trpc.project.delete.mutate(sync.action.id as string);
|
||||
}
|
||||
resolveFn();
|
||||
},
|
||||
})
|
||||
.use(router);
|
||||
.directive("hint", hint)
|
||||
.use(pinia)
|
||||
.use(vueSyncClientPlugin, {
|
||||
dbVersion: 11,
|
||||
schema: [
|
||||
taskStore,
|
||||
apiKeyStore,
|
||||
draftStore,
|
||||
projectStore,
|
||||
projectStatusStore,
|
||||
],
|
||||
onSync: async (sync, resolveFn) => {
|
||||
if (sync.targetTable === draftStore.name) {
|
||||
if (sync.action.actionName === "create")
|
||||
await trpc.draft.create.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "update")
|
||||
await trpc.draft.update.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "delete")
|
||||
await trpc.draft.delete.mutate(sync.action.id as string);
|
||||
} else if (sync.targetTable === taskStore.name) {
|
||||
if (sync.action.actionName === "create")
|
||||
await trpc.task.create.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "update")
|
||||
await trpc.task.update.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "delete")
|
||||
await trpc.task.delete.mutate(sync.action.id as string);
|
||||
} else if (sync.targetTable === apiKeyStore.name) {
|
||||
if (sync.action.actionName === "create")
|
||||
await trpc.apiKey.create.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "update")
|
||||
await trpc.apiKey.update.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "delete")
|
||||
await trpc.apiKey.delete.mutate(sync.action.id as string);
|
||||
} else if (sync.targetTable === projectStore.name) {
|
||||
if (sync.action.actionName === "create")
|
||||
await trpc.project.create.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "update")
|
||||
await trpc.project.update.mutate(sync.action.data);
|
||||
else if (sync.action.actionName === "delete")
|
||||
await trpc.project.delete.mutate(sync.action.id as string);
|
||||
}
|
||||
resolveFn();
|
||||
},
|
||||
})
|
||||
.use(router);
|
||||
|
||||
function customRule(choice: number, _choicesLength: number) {
|
||||
const calcValue = Math.abs(choice) % 100;
|
||||
const num = calcValue % 10;
|
||||
const calcValue = Math.abs(choice) % 100;
|
||||
const num = calcValue % 10;
|
||||
|
||||
if (calcValue > 10 && calcValue < 20) return 3;
|
||||
if (num > 1 && num < 5) return 2;
|
||||
if (num === 1) return 1;
|
||||
return 3;
|
||||
if (calcValue > 10 && calcValue < 20) return 3;
|
||||
if (num > 1 && num < 5) return 2;
|
||||
if (num === 1) return 1;
|
||||
return 3;
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
locale: "ru",
|
||||
fallbackLocale: "en",
|
||||
legacy: false,
|
||||
pluralizationRules: {
|
||||
ru: customRule,
|
||||
},
|
||||
locale: "ru",
|
||||
fallbackLocale: "en",
|
||||
legacy: false,
|
||||
pluralizationRules: {
|
||||
ru: customRule,
|
||||
},
|
||||
});
|
||||
app.use(i18n);
|
||||
|
||||
|
||||
@ -7,48 +7,48 @@ import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
createSVGSpritePlugin({
|
||||
svgFolder: "./src/assets/svg",
|
||||
transformIndexHtmlTag: {
|
||||
injectTo: "body",
|
||||
},
|
||||
}),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
injectRegister: "auto",
|
||||
workbox: { globPatterns: ["**/*.{js,css,html,ico,png,svg}"] },
|
||||
}),
|
||||
sentryVitePlugin({
|
||||
org: "me-sgp",
|
||||
project: "cubicdone",
|
||||
}),
|
||||
plugins: [
|
||||
vue(),
|
||||
createSVGSpritePlugin({
|
||||
svgFolder: "./src/assets/svg",
|
||||
transformIndexHtmlTag: {
|
||||
injectTo: "body",
|
||||
},
|
||||
}),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
injectRegister: "auto",
|
||||
workbox: { globPatterns: ["**/*.{js,css,html,ico,png,svg}"] },
|
||||
}),
|
||||
sentryVitePlugin({
|
||||
org: "me-sgp",
|
||||
project: "cubicdone",
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "@components",
|
||||
replacement: path.resolve(__dirname, "./src/components"),
|
||||
},
|
||||
{ find: "@store", replacement: path.resolve(__dirname, "./src/store") },
|
||||
{ find: "@models", replacement: path.resolve(__dirname, "./src/models") },
|
||||
{ find: "@utils", replacement: path.resolve(__dirname, "./src/utils") },
|
||||
{ find: "@assets", replacement: path.resolve(__dirname, "./src/assets") },
|
||||
],
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "@components",
|
||||
replacement: path.resolve(__dirname, "./src/components"),
|
||||
},
|
||||
{ find: "@store", replacement: path.resolve(__dirname, "./src/store") },
|
||||
{ find: "@models", replacement: path.resolve(__dirname, "./src/models") },
|
||||
{ find: "@utils", replacement: path.resolve(__dirname, "./src/utils") },
|
||||
{ find: "@assets", replacement: path.resolve(__dirname, "./src/assets") },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
build: {
|
||||
target: "esnext",
|
||||
sourcemap: true,
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
sourcemap: true,
|
||||
},
|
||||
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,31 +1,30 @@
|
||||
import { createContext, router } from "./trpc";
|
||||
import cors from "cors";
|
||||
import { drafts } from "./router/draft.router";
|
||||
import { tasks } from "./router/tasks.router";
|
||||
import { projects } from "./router/project.router";
|
||||
import { apiKeys } from "./router/apikey.router";
|
||||
import { projectStatus } from "./router/project-status.router";
|
||||
import cookieParser from "cookie-parser";
|
||||
import cors from "cors";
|
||||
import { apiKeys } from "./router/apikey.router";
|
||||
import { drafts } from "./router/draft.router";
|
||||
import { projectStatus } from "./router/project-status.router";
|
||||
import { projects } from "./router/project.router";
|
||||
import { tasks } from "./router/tasks.router";
|
||||
import { createContext, router } from "./trpc";
|
||||
|
||||
import express from "express";
|
||||
import * as trpcExpress from "@trpc/server/adapters/express";
|
||||
import express from "express";
|
||||
import { webcrypto } from "node:crypto";
|
||||
import { authRouter } from "./router/auth.router";
|
||||
import {
|
||||
oauthRedirectRouter,
|
||||
sameOauthState,
|
||||
oauthRedirectRouter
|
||||
} from "./webhooks/oauth-redirects";
|
||||
import { oauthUrlRouter } from "./webhooks/oauth-url";
|
||||
|
||||
globalThis.crypto = webcrypto as Crypto;
|
||||
|
||||
const appRouter = router({
|
||||
draft: drafts,
|
||||
task: tasks,
|
||||
project: projects,
|
||||
apiKey: apiKeys,
|
||||
projectStatus: projectStatus,
|
||||
auth: authRouter,
|
||||
draft: drafts,
|
||||
task: tasks,
|
||||
project: projects,
|
||||
apiKey: apiKeys,
|
||||
projectStatus: projectStatus,
|
||||
auth: authRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@ -33,10 +32,10 @@ export type AppRouter = typeof appRouter;
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: ["http://localhost:5173", "https://app.cubicdone.com"],
|
||||
credentials: true,
|
||||
}),
|
||||
cors({
|
||||
origin: ["http://localhost:3000", "https://app.cubicdone.com"],
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(cookieParser());
|
||||
@ -45,7 +44,7 @@ app.use("/oauth/redirect", oauthRedirectRouter);
|
||||
app.use("/oauth", oauthUrlRouter);
|
||||
|
||||
app.use(
|
||||
trpcExpress.createExpressMiddleware({ router: appRouter, createContext }),
|
||||
trpcExpress.createExpressMiddleware({ router: appRouter, createContext }),
|
||||
);
|
||||
|
||||
app.listen(4000);
|
||||
|
||||
@ -1,64 +1,64 @@
|
||||
export const languages = {
|
||||
en: "English",
|
||||
ru: "Русский",
|
||||
en: "English",
|
||||
ru: "Русский",
|
||||
};
|
||||
|
||||
export const defaultLang = "ru";
|
||||
|
||||
export const ui = {
|
||||
en: {
|
||||
"nav.pricing": "Pricing",
|
||||
"nav.signup": "Sign up",
|
||||
"nav.login": "Log in",
|
||||
en: {
|
||||
"nav.pricing": "Pricing",
|
||||
"nav.signup": "Sign up",
|
||||
"nav.login": "Log in",
|
||||
|
||||
"hero.title": `Do not spend days to create
|
||||
"hero.title": `Do not spend days to create
|
||||
<span class="animated-gradient-text">productivity system</span>, we already do that`,
|
||||
"hero.subtitle":
|
||||
"TODO List with preconfigured productivity system, that will keep your tasks and mind in shape",
|
||||
"hero.action": "Try it now!",
|
||||
"hero.subtitle":
|
||||
"TODO List with preconfigured productivity system, that will keep your tasks and mind in shape",
|
||||
"hero.action": "Try it now!",
|
||||
|
||||
"footer.subtitle":
|
||||
"TODO List with preconfigured productivity system, that will keep your tasks and mind in shape",
|
||||
"footer.subtitle":
|
||||
"TODO List with preconfigured productivity system, that will keep your tasks and mind in shape",
|
||||
|
||||
"footer.company": "Company",
|
||||
"footer.about": "About us",
|
||||
"footer.sitemap": "Sitemap",
|
||||
"footer.company": "Company",
|
||||
"footer.about": "About us",
|
||||
"footer.sitemap": "Sitemap",
|
||||
|
||||
"footer.legal": "Legal",
|
||||
"footer.privacy": "Privacy",
|
||||
"footer.terms": "Terms of service",
|
||||
"footer.legal": "Legal",
|
||||
"footer.privacy": "Privacy",
|
||||
"footer.terms": "Terms of service",
|
||||
|
||||
"footer.support": "Support",
|
||||
"footer.faq": "FAQ",
|
||||
"footer.contact": "Contact",
|
||||
"footer.support": "Support",
|
||||
"footer.faq": "FAQ",
|
||||
"footer.contact": "Contact",
|
||||
|
||||
askquestion: "Ask a question",
|
||||
},
|
||||
ru: {
|
||||
"nav.pricing": "Стоимость",
|
||||
"nav.signup": "Регистрация",
|
||||
"nav.login": "Вход",
|
||||
askquestion: "Ask a question",
|
||||
},
|
||||
ru: {
|
||||
"nav.pricing": "Стоимость",
|
||||
"nav.signup": "Регистрация",
|
||||
"nav.login": "Вход",
|
||||
|
||||
"hero.title": `Не тратьте дни на создание <span class="animated-gradient-text">Системы продуктивности</span>, мы уже сделали это за вас`,
|
||||
"hero.subtitle":
|
||||
"Список дел с подготовленной системой продуктивности, которая сохранит ваши задачи и мышление в тонусе",
|
||||
"hero.action": "Пробовать сейчас!",
|
||||
"hero.title": `Не тратьте дни на создание <span class="animated-gradient-text">Системы продуктивности</span>, мы уже сделали это за вас`,
|
||||
"hero.subtitle":
|
||||
"Список дел с подготовленной системой продуктивности, которая сохранит ваши задачи и мышление в тонусе",
|
||||
"hero.action": "Пробовать сейчас!",
|
||||
|
||||
"footer.subtitle":
|
||||
"Список дел с подготовленной системой продуктивности, которая сохранит ваши задачи и мышление в тонусе",
|
||||
"footer.subtitle":
|
||||
"Список дел с подготовленной системой продуктивности, которая сохранит ваши задачи и мышление в тонусе",
|
||||
|
||||
"footer.company": "Компания",
|
||||
"footer.about": "О нас",
|
||||
"footer.sitemap": "Sitemap",
|
||||
"footer.company": "Компания",
|
||||
"footer.about": "О нас",
|
||||
"footer.sitemap": "Sitemap",
|
||||
|
||||
"footer.legal": "Юр. вопросы",
|
||||
"footer.privacy": "Приватность",
|
||||
"footer.terms": "Условия использования",
|
||||
"footer.legal": "Юр. вопросы",
|
||||
"footer.privacy": "Приватность",
|
||||
"footer.terms": "Условия использования",
|
||||
|
||||
"footer.support": "Поддержка",
|
||||
"footer.faq": "FAQ",
|
||||
"footer.contact": "Контакты",
|
||||
"footer.support": "Поддержка",
|
||||
"footer.faq": "FAQ",
|
||||
"footer.contact": "Контакты",
|
||||
|
||||
askquestion: "Задать вопрос",
|
||||
},
|
||||
askquestion: "Задать вопрос",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { defaultLang, ui } from "./ui";
|
||||
|
||||
export function getLangFromUrl(url: URL) {
|
||||
const [, lang] = url.pathname.split("/");
|
||||
if (lang! in ui) return lang as keyof typeof ui;
|
||||
return defaultLang;
|
||||
const [, lang] = url.pathname.split("/");
|
||||
if (lang! in ui) return lang as keyof typeof ui;
|
||||
return defaultLang;
|
||||
}
|
||||
|
||||
export function useTranslations(lang: keyof typeof ui) {
|
||||
return function t(key: keyof (typeof ui)[typeof defaultLang]) {
|
||||
return ui[lang][key] || ui[defaultLang][key];
|
||||
};
|
||||
return function t(key: keyof (typeof ui)[typeof defaultLang]) {
|
||||
return ui[lang][key] || ui[defaultLang][key];
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user