forked from Mirrors/elk
Merge branch 'main' into userquin/feat-track-scroll-position
# Conflicts: # pages/settings/language/index.vue
This commit is contained in:
commit
97866ebeab
35 changed files with 1431 additions and 797 deletions
|
@ -9,3 +9,5 @@ public/
|
|||
https-dev-config/localhost.crt
|
||||
https-dev-config/localhost.key
|
||||
Dockerfile
|
||||
elk-translation-status.json
|
||||
docs/translation-status.json
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -9,6 +9,7 @@ dist
|
|||
.vite-inspect
|
||||
.netlify/
|
||||
.eslintcache
|
||||
elk-translation-status.json
|
||||
|
||||
public/shiki
|
||||
public/emojis
|
||||
|
|
|
@ -10,7 +10,7 @@ const emit = defineEmits<{
|
|||
<div i-ri:close-line />
|
||||
</button>
|
||||
|
||||
<img :alt="$t('app_logo')" src="/logo.svg" w-20 h-20 height="80" width="80" mxa class="rtl-flip">
|
||||
<img :alt="$t('app_logo')" :src="`/${''}logo.svg`" w-20 h-20 height="80" width="80" mxa class="rtl-flip">
|
||||
<h1 mxa text-4xl mb4>
|
||||
{{ $t('help.title') }}
|
||||
</h1>
|
||||
|
|
|
@ -27,7 +27,7 @@ const emit = defineEmits<{
|
|||
const { t } = useI18n()
|
||||
|
||||
const draftState = useDraft(draftKey, initial)
|
||||
const { draft } = $(draftState)
|
||||
const { draft, isEmpty } = $(draftState)
|
||||
|
||||
const {
|
||||
isExceedingAttachmentLimit, isUploading, failedAttachments, isOverDropZone,
|
||||
|
@ -48,6 +48,8 @@ const { editor } = useTiptap({
|
|||
set: (newVal) => {
|
||||
draft.params.status = newVal
|
||||
draft.lastUpdated = Date.now()
|
||||
if (isEmpty)
|
||||
clearEmptyDrafts()
|
||||
},
|
||||
}),
|
||||
placeholder: computed(() => placeholder ?? draft.params.inReplyToId ? t('placeholder.replying') : t('placeholder.default_1')),
|
||||
|
|
|
@ -96,7 +96,7 @@ onClickOutside(input, () => {
|
|||
<template>
|
||||
<form text-center justify-center items-center max-w-150 py6 flex="~ col gap-3" @submit.prevent="oauth">
|
||||
<div flex="~ center" items-end mb2 gap-x-2>
|
||||
<img src="/logo.svg" w-12 h-12 mxa height="48" width="48" :alt="$t('app_logo')" class="rtl-flip">
|
||||
<img :src="`/${''}logo.svg`" w-12 h-12 mxa height="48" width="48" :alt="$t('app_logo')" class="rtl-flip">
|
||||
<div text-3xl>
|
||||
{{ $t('action.sign_in') }}
|
||||
</div>
|
||||
|
|
|
@ -10,7 +10,7 @@ const { busy, oauth, singleInstanceServer } = useSignIn()
|
|||
</i18n-t>
|
||||
</p>
|
||||
<p text-sm text-secondary>
|
||||
{{ $t('user.sign_in_desc') }}
|
||||
{{ $t(singleInstanceServer ? 'user.single_instance_sign_in_desc' : 'user.sign_in_desc') }}
|
||||
</p>
|
||||
<button
|
||||
v-if="singleInstanceServer"
|
||||
|
|
|
@ -30,7 +30,7 @@ export function getDefaultDraft(options: Partial<Mutable<mastodon.v1.CreateStatu
|
|||
params: {
|
||||
status: status || '',
|
||||
inReplyToId,
|
||||
visibility: visibility || 'public',
|
||||
visibility: currentUser.value?.account.source.privacy || visibility || 'public',
|
||||
sensitive: sensitive ?? false,
|
||||
spoilerText: spoilerText || '',
|
||||
language: language || '', // auto inferred from current language on posting
|
||||
|
@ -141,7 +141,7 @@ export function directMessageUser(account: mastodon.v1.Account) {
|
|||
|
||||
export function clearEmptyDrafts() {
|
||||
for (const key in currentUserDrafts.value) {
|
||||
if (builtinDraftKeys.includes(key))
|
||||
if (builtinDraftKeys.includes(key) && !isEmptyDraft(currentUserDrafts.value[key]))
|
||||
continue
|
||||
if (!currentUserDrafts.value[key].params || isEmptyDraft(currentUserDrafts.value[key]))
|
||||
delete currentUserDrafts.value[key]
|
||||
|
|
|
@ -27,9 +27,11 @@ export function emojisArrayToObject(emojis: mastodon.v1.CustomEmoji[]) {
|
|||
|
||||
export function noop() {}
|
||||
|
||||
export const useIsMac = () => computed(() =>
|
||||
useRequestHeaders(['user-agent'])['user-agent']?.includes('Macintosh')
|
||||
export const useIsMac = () => {
|
||||
const headers = useRequestHeaders(['user-agent'])
|
||||
return computed(() => headers['user-agent']?.includes('Macintosh')
|
||||
?? navigator?.platform?.includes('Mac') ?? false)
|
||||
}
|
||||
|
||||
export const isEmptyObject = (object: Object) => Object.keys(object).length === 0
|
||||
|
||||
|
|
|
@ -8,14 +8,14 @@ export function setupPageHeader() {
|
|||
const enablePinchToZoom = usePreferences('enablePinchToZoom')
|
||||
|
||||
const localeMap = (locales.value as LocaleObject[]).reduce((acc, l) => {
|
||||
acc[l.code!] = l.dir ?? 'auto'
|
||||
acc[l.code!] = l.dir ?? 'ltr'
|
||||
return acc
|
||||
}, {} as Record<string, Directions>)
|
||||
|
||||
useHeadFixed({
|
||||
htmlAttrs: {
|
||||
lang: () => locale.value,
|
||||
dir: () => localeMap[locale.value] ?? 'auto',
|
||||
dir: () => localeMap[locale.value] ?? 'ltr',
|
||||
class: () => enablePinchToZoom.value ? ['enable-pinch-to-zoom'] : [],
|
||||
},
|
||||
meta: [{
|
||||
|
|
|
@ -198,7 +198,7 @@ const buildLocales = () => {
|
|||
return useLocales.sort((a, b) => a.code.localeCompare(b.code))
|
||||
}
|
||||
|
||||
const currentLocales = buildLocales()
|
||||
export const currentLocales = buildLocales()
|
||||
|
||||
const datetimeFormats = Object.values(currentLocales).reduce((acc, data) => {
|
||||
const dateTimeFormats = data.dateTimeFormats
|
||||
|
|
1
docs/.gitignore
vendored
1
docs/.gitignore
vendored
|
@ -10,3 +10,4 @@ dist
|
|||
sw.*
|
||||
.env
|
||||
.output
|
||||
translation-status.json
|
||||
|
|
15
docs/components/global/ClipboardIcon.vue
Normal file
15
docs/components/global/ClipboardIcon.vue
Normal file
|
@ -0,0 +1,15 @@
|
|||
<script>
|
||||
export default {
|
||||
name: 'ClipboardIcon',
|
||||
props: { copy: Boolean },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="copy" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2v2Z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-7 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1M7 7h10V5h2v14H5V5h2v2m.5 6.5L9 12l2 2l4.5-4.5L17 11l-6 6l-3.5-3.5Z" />
|
||||
</svg>
|
||||
</template>
|
15
docs/components/global/ToogleIcon.vue
Normal file
15
docs/components/global/ToogleIcon.vue
Normal file
|
@ -0,0 +1,15 @@
|
|||
<script>
|
||||
export default {
|
||||
name: 'ToogleIcon',
|
||||
props: { up: Boolean },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="up" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="m12 10.828l-4.95 4.95l-1.414-1.414L12 8l6.364 6.364l-1.414 1.414z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="m12 13.172l4.95-4.95l1.414 1.414L12 16L5.636 9.636L7.05 8.222z" />
|
||||
</svg>
|
||||
</template>
|
338
docs/components/global/TranslationState.vue
Normal file
338
docs/components/global/TranslationState.vue
Normal file
|
@ -0,0 +1,338 @@
|
|||
<script setup lang="ts">
|
||||
import type { TranslationStatus } from '../../types'
|
||||
|
||||
const localesStatuses: TranslationStatus = await import('../../translation-status.json').then(m => m.default)
|
||||
|
||||
const totalReference = localesStatuses.en.total
|
||||
|
||||
type Tab = 'missing' | 'outdated'
|
||||
|
||||
const hidden = ref(true)
|
||||
const locale = ref()
|
||||
const localeTab = ref<Tab>('missing')
|
||||
const copied = ref(false)
|
||||
|
||||
const currentLocale = computed(() => {
|
||||
if (hidden.value || !locale.value)
|
||||
return undefined
|
||||
|
||||
return localesStatuses as Record<string, any>
|
||||
})
|
||||
|
||||
const localeTitle = computed(() => {
|
||||
if (hidden.value || !locale.value)
|
||||
return undefined
|
||||
|
||||
return localeTab.value === 'missing'
|
||||
? `Missing keys in ${locale.value.file}`
|
||||
: `Outdated keys in ${locale.value.file}`
|
||||
})
|
||||
|
||||
const missingEntries = computed<string[]>(() => {
|
||||
if (hidden.value || !currentLocale.value || localeTab.value !== 'missing')
|
||||
return []
|
||||
|
||||
return localesStatuses[locale.value].missing
|
||||
})
|
||||
|
||||
const outdatedEntries = computed<string[]>(() => {
|
||||
if (hidden.value || !currentLocale.value || localeTab.value !== 'outdated')
|
||||
return []
|
||||
|
||||
return localesStatuses[locale.value]!.outdated
|
||||
})
|
||||
|
||||
const showDetail = (key: string, tab: Tab = 'missing', fromTab = false) => {
|
||||
if (key === locale.value && tab === localeTab.value) {
|
||||
if (fromTab)
|
||||
return
|
||||
|
||||
nextTick().then(() => hidden.value = !hidden.value)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
locale.value = key
|
||||
localeTab.value = tab
|
||||
nextTick().then(() => hidden.value = false)
|
||||
}
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText([
|
||||
`# ${localeTitle.value}`,
|
||||
(localeTab.value === 'missing' ? missingEntries.value : outdatedEntries.value).join('\n')].join('\n'),
|
||||
)
|
||||
copied.value = true
|
||||
setTimeout(() => copied.value = false, 750)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<table class="w-full">
|
||||
<caption>
|
||||
<div>You can see the detail (missing and outdated keys) by clicking on the corresponding row.</div>
|
||||
<div>
|
||||
If you want to send a PR, click on <strong>Edit</strong> link on the corresponding translation file, it will open <strong>Codeflow</strong>:
|
||||
<NuxtLink
|
||||
target="_blank"
|
||||
href="https://developer.stackblitz.com/codeflow/working-in-codeflow-ide#making-a-pr-with-codeflow-ide"
|
||||
title="How to make a PR with Codeflow IDE (opens in new window)"
|
||||
>
|
||||
read the following guide
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Language</th>
|
||||
<th title="Keys correctly translated">
|
||||
Translated
|
||||
</th>
|
||||
<th title="Keys missing from source which need translation for the language">
|
||||
Missing
|
||||
</th>
|
||||
<th title="Keys which could be safely removed">
|
||||
Outdated
|
||||
</th>
|
||||
<th>Total</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="({ title, file, translated, missing, outdated, total, isSource }, key) in localesStatuses" :key="key">
|
||||
<tr
|
||||
v-if="totalReference > 0"
|
||||
:class="[{ expandable: !isSource }]"
|
||||
:title="!isSource ? 'Click to show detail' : undefined"
|
||||
@click="!isSource && showDetail(key, 'missing')"
|
||||
>
|
||||
<td :class="[{ expandable: !isSource }]">
|
||||
<div>
|
||||
<ToogleIcon v-if="!isSource" :up="hidden || key !== locale" />
|
||||
{{ title }}
|
||||
</div>
|
||||
</td>
|
||||
<template v-if="isSource">
|
||||
<td colspan="5" class="source-text">
|
||||
<div>
|
||||
{{ total }} keys as source
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td>
|
||||
<strong>{{ `${translated?.length ?? 0}` }}</strong> {{ `(${(100 * (translated?.length ?? 0) / totalReference).toFixed(1)}%)` }}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ `${missing?.length ?? 0}` }}</strong> {{ `(${(100 * (missing?.length ?? 0) / totalReference).toFixed(1)}%)` }}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ `${outdated?.length ?? 0}` }}</strong> {{ `(${(100 * (outdated?.length ?? 0) / totalReference).toFixed(1)}%)` }}
|
||||
</td>
|
||||
<td><strong>{{ `${total}` }}</strong></td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="outdated.length > 0 || missing.length > 0"
|
||||
:href="`https://pr.new/github.com/elk-zone/elk/tree/main/locales/${file}`"
|
||||
target="_blank"
|
||||
class="codeflow"
|
||||
title="Raise a PR with Codeflow (opens in new window)"
|
||||
@click.stop
|
||||
>
|
||||
Edit
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M5 21q-.825 0-1.413-.587Q3 19.825 3 19V5q0-.825.587-1.413Q4.175 3 5 3h7v2H5v14h14v-7h2v7q0 .825-.587 1.413Q19.825 21 19 21Zm4.7-5.3l-1.4-1.4L17.6 5H14V3h7v7h-2V6.4Z" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<template v-if="key === locale && !hidden">
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<div class="detail">
|
||||
<header>
|
||||
<h2 class="tabs">
|
||||
<button
|
||||
:class="localeTab === 'missing' ? 'current' : null"
|
||||
@click="showDetail(key, 'missing', true)"
|
||||
>
|
||||
Missing keys
|
||||
</button>
|
||||
<button
|
||||
:class="localeTab === 'outdated' ? 'current' : null"
|
||||
@click="showDetail(key, 'outdated', true)"
|
||||
>
|
||||
Outdated keys
|
||||
</button>
|
||||
</h2>
|
||||
</header>
|
||||
<ul v-if="localeTab === 'missing'">
|
||||
<li v-for="entry in missingEntries" :key="entry">
|
||||
<pre>{{ entry }}</pre>
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-else>
|
||||
<li v-for="entry in outdatedEntries" :key="entry">
|
||||
<pre>{{ entry }}</pre>
|
||||
</li>
|
||||
</ul>
|
||||
<button @click="copyToClipboard()">
|
||||
<ClipboardIcon :copy="!copied" />
|
||||
Copy to clipboard
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
table {
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding: 0.3rem;
|
||||
background: #eee;
|
||||
border: 1px solid #ccc;
|
||||
border-top-left-radius: 3px;
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom: none;
|
||||
}
|
||||
caption a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
th:not(:first-of-type),
|
||||
td:not(:first-of-type) {
|
||||
border-left: 1px solid #eee;
|
||||
}
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
tr.expandable td:first-of-type {
|
||||
padding-left: 4px;
|
||||
}
|
||||
tr.expandable, tr.expandable td {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.codeflow,
|
||||
td.expandable div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
column-gap: 4px;
|
||||
}
|
||||
td.expandable > svg {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
tbody tr td {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
th[title] {
|
||||
text-decoration: underline dotted white;
|
||||
}
|
||||
|
||||
.source-text {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
padding: 10px 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.detail {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.detail header {
|
||||
padding: 0 0.3rem;
|
||||
display: flex;
|
||||
background: #eee;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail header h2 button {
|
||||
font-weight: bold;
|
||||
padding: 0.5rem;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.detail header .tabs button.current {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.detail header .tabs + .heading-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail ul {
|
||||
padding: 0.3rem 0.5rem;
|
||||
max-height: 250px;
|
||||
min-height: 250px;
|
||||
overflow-y: auto;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.detail > button {
|
||||
display: flex;
|
||||
/*justify-content: space-between;*/
|
||||
align-items: center;
|
||||
column-gap: 0.3rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.detail header {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.detail header h2 button {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.detail header .tabs button.current {
|
||||
background-color: white;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
table caption {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -34,6 +34,10 @@ Elk uses [Vitest](https://vitest.dev). You can run the test suite with:
|
|||
nr test
|
||||
```
|
||||
|
||||
## Translation status
|
||||
|
||||
<TranslationState />
|
||||
|
||||
# Stack
|
||||
|
||||
- [Vite](https://vitejs.dev/) - Next Generation Frontend Tooling
|
||||
|
|
200
docs/package-lock.json
generated
Normal file
200
docs/package-lock.json
generated
Normal file
|
@ -0,0 +1,200 @@
|
|||
{
|
||||
"name": "elk-docs",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "elk-docs",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@nuxt-themes/docus": "^1.6.1",
|
||||
"@types/flat": "^5.0.2",
|
||||
"flat": "^5.0.2",
|
||||
"flatten": "^1.0.3",
|
||||
"iso-639-1": "^2.1.15",
|
||||
"nuxt": "^3.1.1",
|
||||
"vite-plugin-virtual": "^0.1.1"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/@nuxt-themes+docus@1.6.3_nuxt@3.1.1/node_modules/@nuxt-themes/docus": {
|
||||
"version": "1.6.3",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@nuxt-themes/elements": "^0.5.2",
|
||||
"@nuxt-themes/tokens": "^1.6.2",
|
||||
"@nuxt-themes/typography": "^0.6.0",
|
||||
"@nuxt/content": "^2.4.1",
|
||||
"@nuxthq/studio": "^0.6.5",
|
||||
"@vueuse/nuxt": "^9.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@algolia/client-search": "^4.14.3",
|
||||
"@docsearch/css": "^3.3.2",
|
||||
"@docsearch/js": "^3.3.2",
|
||||
"@nuxtjs/algolia": "^1.5.0",
|
||||
"@nuxtjs/eslint-config-typescript": "^12.0.0",
|
||||
"eslint": "^8.32.0",
|
||||
"nuxt": "3.1.1",
|
||||
"nuxt-plausible": "^0.1.2",
|
||||
"release-it": "^15.6.0",
|
||||
"typescript": "^4.9.4",
|
||||
"vue": "^3.2.45"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/flat@5.0.2/node_modules/flat": {
|
||||
"version": "5.0.2",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"flat": "cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mocha": "~8.1.1",
|
||||
"standard": "^14.3.4"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/flatten@1.0.3/node_modules/flatten": {
|
||||
"version": "1.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"devDependencies": {}
|
||||
},
|
||||
"../node_modules/.pnpm/iso-639-1@2.1.15/node_modules/iso-639-1": {
|
||||
"version": "2.1.15",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-core": "^6.26.0",
|
||||
"babel-loader": "^7.1.2",
|
||||
"babel-plugin-add-module-exports": "^0.2.1",
|
||||
"babel-plugin-transform-runtime": "^6.23.0",
|
||||
"babel-preset-es2015": "^6.24.1",
|
||||
"babel-preset-stage-0": "^6.24.1",
|
||||
"clean-webpack-plugin": "^0.1.17",
|
||||
"mocha": "^4.0.1",
|
||||
"webpack": "^3.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/nuxt@3.1.1/node_modules/nuxt": {
|
||||
"version": "3.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nuxt/devalue": "^2.0.0",
|
||||
"@nuxt/kit": "3.1.1",
|
||||
"@nuxt/schema": "3.1.1",
|
||||
"@nuxt/telemetry": "^2.1.9",
|
||||
"@nuxt/ui-templates": "^1.1.0",
|
||||
"@nuxt/vite-builder": "3.1.1",
|
||||
"@unhead/ssr": "^1.0.18",
|
||||
"@vue/reactivity": "^3.2.45",
|
||||
"@vue/shared": "^3.2.45",
|
||||
"@vueuse/head": "^1.0.23",
|
||||
"chokidar": "^3.5.3",
|
||||
"cookie-es": "^0.5.0",
|
||||
"defu": "^6.1.2",
|
||||
"destr": "^1.2.2",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"fs-extra": "^11.1.0",
|
||||
"globby": "^13.1.3",
|
||||
"h3": "^1.0.2",
|
||||
"hash-sum": "^2.0.0",
|
||||
"hookable": "^5.4.2",
|
||||
"jiti": "^1.16.2",
|
||||
"knitwork": "^1.0.0",
|
||||
"magic-string": "^0.27.0",
|
||||
"mlly": "^1.1.0",
|
||||
"nitropack": "^2.0.0",
|
||||
"nuxi": "3.1.1",
|
||||
"ofetch": "^1.0.0",
|
||||
"ohash": "^1.0.0",
|
||||
"pathe": "^1.1.0",
|
||||
"perfect-debounce": "^0.1.3",
|
||||
"scule": "^1.0.0",
|
||||
"strip-literal": "^1.0.0",
|
||||
"ufo": "^1.0.1",
|
||||
"ultrahtml": "^1.2.0",
|
||||
"unctx": "^2.1.1",
|
||||
"unenv": "^1.0.1",
|
||||
"unhead": "^1.0.18",
|
||||
"unimport": "^2.0.1",
|
||||
"unplugin": "^1.0.1",
|
||||
"untyped": "^1.2.2",
|
||||
"vue": "^3.2.45",
|
||||
"vue-bundle-renderer": "^1.0.0",
|
||||
"vue-devtools-stub": "^0.1.0",
|
||||
"vue-router": "^4.1.6"
|
||||
},
|
||||
"bin": {
|
||||
"nuxi": "bin/nuxt.mjs",
|
||||
"nuxt": "bin/nuxt.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/hash-sum": "^1.0.0",
|
||||
"unbuild": "latest"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.16.0 || ^16.10.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"../node_modules/.pnpm/vite-plugin-virtual@0.1.1/node_modules/vite-plugin-virtual": {
|
||||
"version": "0.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^0.6.2",
|
||||
"@types/jest": "^26.0.22",
|
||||
"@types/node": "^14.14.37",
|
||||
"@typescript-eslint/eslint-plugin": "^4.20.0",
|
||||
"eslint": "^7.23.0",
|
||||
"jest": "^26.6.3",
|
||||
"jest-esbuild": "^0.1.5",
|
||||
"rollup": "^2.44.0",
|
||||
"ts-node": "^9.1.1",
|
||||
"tsup": "^4.8.21",
|
||||
"typescript": "^4.2.3",
|
||||
"vite": "^2.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt-themes/docus": {
|
||||
"resolved": "../node_modules/.pnpm/@nuxt-themes+docus@1.6.3_nuxt@3.1.1/node_modules/@nuxt-themes/docus",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/flat": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/flat/-/flat-5.0.2.tgz",
|
||||
"integrity": "sha512-3zsplnP2djeps5P9OyarTxwRpMLoe5Ash8aL9iprw0JxB+FAHjY+ifn4yZUuW4/9hqtnmor6uvjSRzJhiVbrEQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/flat": {
|
||||
"resolved": "../node_modules/.pnpm/flat@5.0.2/node_modules/flat",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/flatten": {
|
||||
"resolved": "../node_modules/.pnpm/flatten@1.0.3/node_modules/flatten",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/iso-639-1": {
|
||||
"resolved": "../node_modules/.pnpm/iso-639-1@2.1.15/node_modules/iso-639-1",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/nuxt": {
|
||||
"resolved": "../node_modules/.pnpm/nuxt@3.1.1/node_modules/nuxt",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/vite-plugin-virtual": {
|
||||
"resolved": "../node_modules/.pnpm/vite-plugin-virtual@0.1.1/node_modules/vite-plugin-virtual",
|
||||
"link": true
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@
|
|||
"preview": "nuxi preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt-themes/docus": "^1.6.1",
|
||||
"nuxt": "^3.1.1"
|
||||
"@nuxt-themes/docus": "^1.8.1",
|
||||
"nuxt": "^3.2.0"
|
||||
}
|
||||
}
|
||||
|
|
11
docs/types.ts
Normal file
11
docs/types.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export interface LocaleEntry {
|
||||
title: string
|
||||
file: string
|
||||
translated: string[]
|
||||
missing: string[]
|
||||
outdated: string[]
|
||||
total: number
|
||||
isSource?: boolean
|
||||
}
|
||||
|
||||
export type TranslationStatus = Record<string, LocaleEntry>
|
|
@ -337,6 +337,7 @@
|
|||
"language": {
|
||||
"display_language": "Display Language",
|
||||
"label": "Language",
|
||||
"status": "Translation status: {0}/{1} ({2}%)",
|
||||
"translations": {
|
||||
"add": "Add",
|
||||
"choose_language": "Choose language",
|
||||
|
@ -569,6 +570,7 @@
|
|||
"sign_in_desc": "Sign in to follow profiles or hashtags, favorite, share and reply to posts, or interact from your account on a different server.",
|
||||
"sign_in_notice_title": "Viewing {0} public data",
|
||||
"sign_out_account": "Sign out {0}",
|
||||
"single_instance_sign_in_desc": "Sign in to follow profiles or hashtags, favorite, share and reply to posts.",
|
||||
"tip_no_account": "If you don't have a Mastodon account yet, {0}.",
|
||||
"tip_register_account": "pick your server and register one"
|
||||
},
|
||||
|
|
|
@ -337,6 +337,7 @@
|
|||
"language": {
|
||||
"display_language": "Idioma de pantalla",
|
||||
"label": "Idioma",
|
||||
"status": "Estado traducción: {0}/{1} ({2}%)",
|
||||
"translations": {
|
||||
"add": "Agregar",
|
||||
"choose_language": "Seleccionar idioma",
|
||||
|
@ -405,16 +406,19 @@
|
|||
"github_cards": "Tarjetas GitHub",
|
||||
"grayscale_mode": "Modo escala de grises",
|
||||
"hide_account_hover_card": "Ocultar tarjeta flotante de cuenta",
|
||||
"hide_alt_indi_on_posts": "Ocultar indicador ALT en publicaciones",
|
||||
"hide_boost_count": "Ocultar contador de retoots",
|
||||
"hide_favorite_count": "Ocultar número de publicaciones favoritas",
|
||||
"hide_follower_count": "Ocultar número de seguidores",
|
||||
"hide_reply_count": "Ocultar número de respuestas",
|
||||
"hide_translation": "Ocultar traducción",
|
||||
"hide_username_emojis": "Ocultar emojis en el nombre de usuario",
|
||||
"hide_username_emojis_description": "Se ocultan los emojis en el nombre de usuario en las líneas de tiempo. Los emojis seguirán siendo visibles en sus perfiles.",
|
||||
"label": "Preferencias",
|
||||
"title": "Funcionalidades experimentales",
|
||||
"user_picker": "Selector de usuarios",
|
||||
"virtual_scroll": "Desplazamiento virtual"
|
||||
"virtual_scroll": "Desplazamiento virtual",
|
||||
"wellbeing": "Bienestar"
|
||||
},
|
||||
"profile": {
|
||||
"appearance": {
|
||||
|
@ -463,8 +467,10 @@
|
|||
"filter_removed_phrase": "Eliminado por filtrado",
|
||||
"filter_show_anyway": "Mostrar de todas formas",
|
||||
"img_alt": {
|
||||
"ALT": "ALT",
|
||||
"desc": "Descripción",
|
||||
"dismiss": "Descartar"
|
||||
"dismiss": "Descartar",
|
||||
"read": "Leer la descripción de la imagen {0}"
|
||||
},
|
||||
"poll": {
|
||||
"count": "{0} votos|{0} voto|{0} votos",
|
||||
|
@ -564,6 +570,7 @@
|
|||
"sign_in_desc": "Inicia sesión para seguir perfiles o etiquetas, marcar cómo favorita, compartir y responder a publicaciones, o interactuar con un servidor diferente con tu usuario.",
|
||||
"sign_in_notice_title": "Viendo información pública de {0}",
|
||||
"sign_out_account": "Cerrar sesión {0}",
|
||||
"single_instance_sign_in_desc": "Inicia sesión para seguir perfiles o etiquetas, marcar cómo favorita, compartir y responder a publicaciones.",
|
||||
"tip_no_account": "Si aún no tienes una cuenta Mastodon, {0}.",
|
||||
"tip_register_account": "selecciona tu servidor y registrate"
|
||||
},
|
||||
|
|
|
@ -59,7 +59,7 @@ export default defineNuxtModule<VitePWANuxtOptions>({
|
|||
|
||||
Object.keys(webmanifests!).map(wm => [wm, `manifest-${wm}.webmanifest`]).forEach(([wm, fileName]) => {
|
||||
bundle[fileName] = {
|
||||
isAsset: true,
|
||||
needsCodeReference: false,
|
||||
type: 'asset',
|
||||
name: undefined,
|
||||
source: generateManifest(wm),
|
||||
|
@ -79,6 +79,7 @@ export default defineNuxtModule<VitePWANuxtOptions>({
|
|||
if (entry) {
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', 'application/manifest+json')
|
||||
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate')
|
||||
res.write(JSON.stringify(entry), 'utf-8')
|
||||
res.end()
|
||||
}
|
||||
|
@ -135,15 +136,22 @@ export default defineNuxtModule<VitePWANuxtOptions>({
|
|||
else {
|
||||
nuxt.hook('nitro:config', async (nitroConfig) => {
|
||||
nitroConfig.routeRules = nitroConfig.routeRules || {}
|
||||
nitroConfig.routeRules!['/sw.js'] = {
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=0, must-revalidate',
|
||||
},
|
||||
}
|
||||
for (const locale of pwaLocales) {
|
||||
nitroConfig.routeRules![`/manifest-${locale.code}.webmanifest`] = {
|
||||
headers: {
|
||||
'Content-Type': 'application/manifest+json',
|
||||
'Cache-Control': 'public, max-age=0, must-revalidate',
|
||||
},
|
||||
}
|
||||
nitroConfig.routeRules![`/manifest-${locale.code}-dark.webmanifest`] = {
|
||||
headers: {
|
||||
'Content-Type': 'application/manifest+json',
|
||||
'Cache-Control': 'public, max-age=0, must-revalidate',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
const handlers = [
|
||||
{
|
||||
route: '/api/:server/oauth',
|
||||
handler: defineLazyEventHandler(() => import('~/server/api/[server]/oauth').then(r => r.default || r)),
|
||||
handler: defineLazyEventHandler(() => import('~/server/api/[server]/oauth/[origin]').then(r => r.default || r)),
|
||||
},
|
||||
{
|
||||
route: '/api/:server/login',
|
||||
|
|
|
@ -35,7 +35,6 @@ export default defineNuxtConfig({
|
|||
],
|
||||
experimental: {
|
||||
payloadExtraction: false,
|
||||
reactivityTransform: true,
|
||||
inlineSSRStyles: false,
|
||||
},
|
||||
css: [
|
||||
|
@ -72,15 +71,6 @@ export default defineNuxtConfig({
|
|||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: (id) => {
|
||||
// TODO: find and resolve issue in nuxt/vite/pwa
|
||||
if (id.includes('.svg') || id.includes('entry'))
|
||||
return 'entry'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
postcss: {
|
||||
|
@ -123,6 +113,7 @@ export default defineNuxtConfig({
|
|||
'/manifest.webmanifest': {
|
||||
headers: {
|
||||
'Content-Type': 'application/manifest+json',
|
||||
'Cache-Control': 'public, max-age=0, must-revalidate',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -139,7 +130,7 @@ export default defineNuxtConfig({
|
|||
crawlLinks: true,
|
||||
},
|
||||
},
|
||||
sourcemap: !isDevelopment,
|
||||
sourcemap: isDevelopment,
|
||||
hooks: {
|
||||
'nitro:config': function (config) {
|
||||
const nuxt = useNuxt()
|
||||
|
|
15
package.json
15
package.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@elk-zone/elk",
|
||||
"type": "module",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"packageManager": "pnpm@7.9.0",
|
||||
"license": "MIT",
|
||||
"homepage": "https://elk.zone/",
|
||||
|
@ -23,7 +23,8 @@
|
|||
"test:typecheck": "stale-dep && vue-tsc --noEmit && vue-tsc --noEmit --project service-worker/tsconfig.json",
|
||||
"test": "nr test:unit",
|
||||
"update:team:avatars": "esno scripts/avatars.ts",
|
||||
"postinstall": "ignore-dependency-scripts \"stale-dep -u && simple-git-hooks && nuxi prepare\"",
|
||||
"prepare-translation-status": "esno scripts/prepare-translation-status.ts",
|
||||
"postinstall": "ignore-dependency-scripts \"stale-dep -u && simple-git-hooks && nuxi prepare && nr prepare-translation-status\"",
|
||||
"release": "stale-dep && bumpp && esno scripts/release.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -38,6 +39,7 @@
|
|||
"@iconify-json/ri": "^1.1.4",
|
||||
"@iconify-json/twemoji": "^1.1.10",
|
||||
"@iconify/utils": "^2.0.12",
|
||||
"@nuxt/devtools": "^0.1.0",
|
||||
"@nuxtjs/color-mode": "^3.2.0",
|
||||
"@nuxtjs/i18n": "8.0.0-beta.9",
|
||||
"@pinia/nuxt": "^0.4.6",
|
||||
|
@ -106,9 +108,9 @@
|
|||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^0.34.1",
|
||||
"@antfu/ni": "^0.19.0",
|
||||
"@nuxt/devtools": "^0.1.0",
|
||||
"@types/chroma-js": "^2.1.4",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"@types/flat": "^5.0.2",
|
||||
"@types/fnando__sparkline": "^0.3.4",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
|
@ -117,9 +119,10 @@
|
|||
"bumpp": "^8.2.1",
|
||||
"eslint": "^8.32.0",
|
||||
"esno": "^0.16.3",
|
||||
"flat": "^5.0.2",
|
||||
"fs-extra": "^11.1.0",
|
||||
"lint-staged": "^13.1.0",
|
||||
"nuxt": "3.1.1",
|
||||
"nuxt": "3.2.0",
|
||||
"prettier": "^2.8.3",
|
||||
"simple-git-hooks": "^2.8.1",
|
||||
"typescript": "^4.9.5",
|
||||
|
@ -149,9 +152,7 @@
|
|||
"@tiptap/extension-paragraph": "2.0.0-beta.204",
|
||||
"@tiptap/extension-strike": "2.0.0-beta.204",
|
||||
"@tiptap/extension-text": "2.0.0-beta.204",
|
||||
"vitest>vite": "^3.2.5",
|
||||
"@nuxt/kit": "^3.1.2",
|
||||
"@nuxt/schema": "^3.1.2"
|
||||
"vue": "3.2.45"
|
||||
}
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
|
|
|
@ -29,7 +29,7 @@ const handleShowCommit = () => {
|
|||
</template>
|
||||
|
||||
<div flex="~ col gap4" w-full items-center justify-center my5>
|
||||
<img :alt="$t('app_logo')" src="/logo.svg" w-24 h-24 class="rtl-flip">
|
||||
<img :alt="$t('app_logo')" :src="`${''}/logo.svg`" w-24 h-24 class="rtl-flip">
|
||||
<p text-lg>
|
||||
{{ $t('app_desc_short') }}
|
||||
</p>
|
||||
|
|
|
@ -1,13 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { ElkTranslationStatus } from '~/types/translation-status'
|
||||
|
||||
definePageMeta({
|
||||
noScrollTrack: true,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const translationStatus: ElkTranslationStatus = await import('~/elk-translation-status.json').then(m => m.default)
|
||||
|
||||
useHeadFixed({
|
||||
title: () => `${t('settings.language.label')} | ${t('nav.settings')}`,
|
||||
})
|
||||
const status = computed(() => {
|
||||
const entry = translationStatus.locales[locale.value]
|
||||
return t('settings.language.status', [entry.total, translationStatus.total, entry.percentage])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -19,7 +27,10 @@ useHeadFixed({
|
|||
</template>
|
||||
<div p6>
|
||||
<label space-y-2>
|
||||
<p font-medium>{{ $t('settings.language.display_language') }}</p>
|
||||
<span block font-medium>{{ $t('settings.language.display_language') }}</span>
|
||||
<span block>
|
||||
{{ status }}
|
||||
</span>
|
||||
<SettingsLanguage select-settings />
|
||||
</label>
|
||||
<h2 py4 mt2 font-bold text-xl flex="~ gap-1" items-center>
|
||||
|
|
1086
pnpm-lock.yaml
1086
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
137
scripts/prepare-translation-status.ts
Normal file
137
scripts/prepare-translation-status.ts
Normal file
|
@ -0,0 +1,137 @@
|
|||
import flatten from 'flat'
|
||||
import { createResolver } from '@nuxt/kit'
|
||||
import fs from 'fs-extra'
|
||||
import { currentLocales } from '../config/i18n'
|
||||
import vsCodeConfig from '../.vscode/settings.json'
|
||||
import type { LocaleEntry } from '../docs/types'
|
||||
import type { ElkTranslationStatus } from '~/types/translation-status'
|
||||
|
||||
export const localeData: [code: string, file: string[], title: string][]
|
||||
= currentLocales.map((l: any) => [l.code, l.files ? l.files : [l.file!], l.name ?? l.code])
|
||||
|
||||
function merge(src: Record<string, any>, dst: Record<string, any>) {
|
||||
for (const key in src) {
|
||||
if (typeof src[key] === 'object') {
|
||||
if (!dst[key])
|
||||
dst[key] = {}
|
||||
|
||||
merge(src[key], dst[key])
|
||||
}
|
||||
else {
|
||||
dst[key] = src[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readI18nFile(file: string | string[]) {
|
||||
const resolver = createResolver(import.meta.url)
|
||||
if (Array.isArray(file)) {
|
||||
const files = await Promise.all(file.map(f => async () => {
|
||||
return JSON.parse(Buffer.from(
|
||||
await fs.readFile(resolver.resolve(`../locales/${f}`), 'utf-8'),
|
||||
).toString())
|
||||
})).then(f => f.map(f => f()))
|
||||
const data: Record<string, any> = files[0]
|
||||
files.splice(0, 1)
|
||||
files.forEach(f => merge(f, data))
|
||||
return data
|
||||
}
|
||||
else {
|
||||
return JSON.parse(Buffer.from(
|
||||
await fs.readFile(resolver.resolve(`../locales/${file}`), 'utf-8'),
|
||||
).toString())
|
||||
}
|
||||
}
|
||||
|
||||
async function compare(
|
||||
baseEntries: Record<string, string>,
|
||||
file: string | string[],
|
||||
data: LocaleEntry,
|
||||
) {
|
||||
const baseEntriesKeys = Object.keys(baseEntries)
|
||||
const entries: Record<string, any> = await readI18nFile(file)
|
||||
const flatEntriesKeys = Object.keys(flatten<typeof entries, Record<string, string>>(entries))
|
||||
|
||||
data.translated = flatEntriesKeys.filter(e => baseEntriesKeys.includes(e))
|
||||
data.missing = baseEntriesKeys.filter(e => !flatEntriesKeys.includes(e))
|
||||
data.outdated = flatEntriesKeys.filter(e => !baseEntriesKeys.includes(e))
|
||||
data.total = flatEntriesKeys.length
|
||||
}
|
||||
|
||||
async function prepareTranslationStatus() {
|
||||
const sourceLanguageLocale = localeData.find(l => l[0] === vsCodeConfig['i18n-ally.sourceLanguage'])!
|
||||
const entries: Record<string, any> = await readI18nFile(sourceLanguageLocale[1])
|
||||
const flatEntries = flatten<typeof entries, Record<string, string>>(entries)
|
||||
const total = Object.keys(flatEntries).length
|
||||
const data: Record<string, LocaleEntry> = {
|
||||
en: {
|
||||
translated: [],
|
||||
file: 'en.json',
|
||||
missing: [],
|
||||
outdated: [],
|
||||
title: 'English (source)',
|
||||
total,
|
||||
isSource: true,
|
||||
},
|
||||
}
|
||||
|
||||
await Promise.all(localeData.filter(l => l[0] !== 'en-US').map(async ([code, file, title]) => {
|
||||
console.info(`Comparing ${code}...`, title)
|
||||
data[code] = {
|
||||
title,
|
||||
file: Array.isArray(file) ? file[file.length - 1] : file,
|
||||
translated: [],
|
||||
missing: [],
|
||||
outdated: [],
|
||||
total: 0,
|
||||
}
|
||||
await compare(flatEntries, file, data[code])
|
||||
}))
|
||||
|
||||
const sorted: Record<string, any> = { en: { ...data.en } }
|
||||
|
||||
Object.keys(data).filter(k => k !== 'en').sort((a, b) => {
|
||||
return data[a].translated.length - data[b].translated.length
|
||||
}).forEach((k) => {
|
||||
sorted[k] = { ...data[k] }
|
||||
})
|
||||
|
||||
const resolver = createResolver(import.meta.url)
|
||||
|
||||
await fs.writeFile(
|
||||
resolver.resolve('../docs/translation-status.json'),
|
||||
JSON.stringify(sorted, null, 2),
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
|
||||
const translationStatus: ElkTranslationStatus = {
|
||||
total,
|
||||
locales: {
|
||||
'en-US': {
|
||||
total,
|
||||
percentage: '100',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Object.keys(data).filter(k => k !== 'en').forEach((e) => {
|
||||
const percentage = total <= 0.0 || data[e].total === 0.0
|
||||
? '0'
|
||||
: data[e].total === total
|
||||
? '100'
|
||||
: ((data[e].translated.length / total) * 100).toFixed(1)
|
||||
|
||||
translationStatus.locales[e] = {
|
||||
total: data[e].total,
|
||||
percentage,
|
||||
}
|
||||
})
|
||||
|
||||
await fs.writeFile(
|
||||
resolver.resolve('../elk-translation-status.json'),
|
||||
JSON.stringify(translationStatus, null, 2),
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
}
|
||||
|
||||
prepareTranslationStatus()
|
|
@ -17,9 +17,9 @@ export default defineEventHandler(async (event) => {
|
|||
client_id: app.client_id,
|
||||
force_login: force_login === true ? 'true' : 'false',
|
||||
scope: 'read write follow push',
|
||||
redirect_uri: getRedirectURI(origin, server),
|
||||
response_type: 'code',
|
||||
lang,
|
||||
redirect_uri: getRedirectURI(origin, server),
|
||||
})
|
||||
|
||||
return `https://${server}/oauth/authorize?${query}`
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
import { stringifyQuery } from 'ufo'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { origin } = getQuery(event) as { origin: string }
|
||||
let { server } = getRouterParams(event)
|
||||
server = server.toLocaleLowerCase().trim()
|
||||
const app = await getApp(origin, server)
|
||||
|
||||
if (!app) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `App not registered for server: ${server}`,
|
||||
})
|
||||
}
|
||||
|
||||
const { code } = getQuery(event)
|
||||
if (!code) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
statusMessage: 'Missing authentication code.',
|
||||
})
|
||||
}
|
||||
|
||||
const result: any = await $fetch(`https://${server}/oauth/token`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
client_id: app.client_id,
|
||||
client_secret: app.client_secret,
|
||||
redirect_uri: getRedirectURI(origin, server),
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
scope: 'read write follow push',
|
||||
},
|
||||
retry: 3,
|
||||
})
|
||||
|
||||
const url = `/signin/callback?${stringifyQuery({ server, token: result.access_token, vapid_key: app.vapid_key })}`
|
||||
await sendRedirect(event, url, 302)
|
||||
})
|
47
server/api/[server]/oauth/[origin].ts
Normal file
47
server/api/[server]/oauth/[origin].ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { stringifyQuery } from 'ufo'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
let { server, origin } = getRouterParams(event)
|
||||
server = server.toLocaleLowerCase().trim()
|
||||
origin = decodeURIComponent(origin)
|
||||
const app = await getApp(origin, server)
|
||||
|
||||
if (!app) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `App not registered for server: ${server}`,
|
||||
})
|
||||
}
|
||||
|
||||
const { code } = getQuery(event)
|
||||
if (!code) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
statusMessage: 'Missing authentication code.',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result: any = await $fetch(`https://${server}/oauth/token`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
client_id: app.client_id,
|
||||
client_secret: app.client_secret,
|
||||
redirect_uri: getRedirectURI(origin, server),
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
scope: 'read write follow push',
|
||||
},
|
||||
retry: 3,
|
||||
})
|
||||
|
||||
const url = `/signin/callback?${stringifyQuery({ server, token: result.access_token, vapid_key: app.vapid_key })}`
|
||||
await sendRedirect(event, url, 302)
|
||||
}
|
||||
catch (e) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Could not complete log in.',
|
||||
})
|
||||
}
|
||||
})
|
|
@ -1,10 +1,7 @@
|
|||
import type { Driver } from 'unstorage'
|
||||
// @ts-expect-error unstorage needs to provide backwards-compatible subpath types
|
||||
import _memory from 'unstorage/drivers/memory'
|
||||
import memory from 'unstorage/drivers/memory'
|
||||
import { defineDriver } from 'unstorage'
|
||||
|
||||
const memory = _memory as typeof import('unstorage/dist/drivers/memory')['default']
|
||||
|
||||
export interface CacheDriverOptions {
|
||||
driver: Driver
|
||||
}
|
||||
|
|
|
@ -1,194 +0,0 @@
|
|||
// Temporary hotfix of https://github.com/unjs/unstorage/blob/4d637a117667ae638a6cac657aac139d88a78027/src/drivers/cloudflare-kv-http.ts#L6
|
||||
|
||||
import { $fetch } from 'ofetch'
|
||||
import { defineDriver } from 'unstorage'
|
||||
|
||||
const LOG_TAG = '[unstorage] [cloudflare-http] '
|
||||
|
||||
interface KVAuthAPIToken {
|
||||
/**
|
||||
* API Token generated from the [User Profile 'API Tokens' page](https://dash.cloudflare.com/profile/api-tokens)
|
||||
* of the Cloudflare console.
|
||||
* @see https://api.cloudflare.com/#getting-started-requests
|
||||
*/
|
||||
apiToken: string
|
||||
}
|
||||
|
||||
interface KVAuthServiceKey {
|
||||
/**
|
||||
* A special Cloudflare API key good for a restricted set of endpoints.
|
||||
* Always begins with "v1.0-", may vary in length.
|
||||
* May be used to authenticate in place of `apiToken` or `apiKey` and `email`.
|
||||
* @see https://api.cloudflare.com/#getting-started-requests
|
||||
*/
|
||||
userServiceKey: string
|
||||
}
|
||||
|
||||
interface KVAuthEmailKey {
|
||||
/**
|
||||
* Email address associated with your account.
|
||||
* Should be used along with `apiKey` to authenticate in place of `apiToken`.
|
||||
*/
|
||||
email: string
|
||||
/**
|
||||
* API key generated on the "My Account" page of the Cloudflare console.
|
||||
* Should be used along with `email` to authenticate in place of `apiToken`.
|
||||
* @see https://api.cloudflare.com/#getting-started-requests
|
||||
*/
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export type KVHTTPOptions = {
|
||||
/**
|
||||
* Cloudflare account ID (required)
|
||||
*/
|
||||
accountId: string
|
||||
/**
|
||||
* The ID of the KV namespace to target (required)
|
||||
*/
|
||||
namespaceId: string
|
||||
/**
|
||||
* The URL of the Cloudflare API.
|
||||
* @default https://api.cloudflare.com
|
||||
*/
|
||||
apiURL?: string
|
||||
} & (KVAuthServiceKey | KVAuthAPIToken | KVAuthEmailKey)
|
||||
|
||||
type CloudflareAuthorizationHeaders = {
|
||||
'X-Auth-Email': string
|
||||
'X-Auth-Key': string
|
||||
'X-Auth-User-Service-Key'?: string
|
||||
Authorization?: `Bearer ${string}`
|
||||
} | {
|
||||
'X-Auth-Email'?: string
|
||||
'X-Auth-Key'?: string
|
||||
'X-Auth-User-Service-Key': string
|
||||
Authorization?: `Bearer ${string}`
|
||||
} | {
|
||||
'X-Auth-Email'?: string
|
||||
'X-Auth-Key'?: string
|
||||
'X-Auth-User-Service-Key'?: string
|
||||
Authorization: `Bearer ${string}`
|
||||
}
|
||||
|
||||
export default defineDriver<KVHTTPOptions>((opts) => {
|
||||
if (!opts)
|
||||
throw new Error('Options must be provided.')
|
||||
|
||||
if (!opts.accountId)
|
||||
throw new Error(`${LOG_TAG}\`accountId\` is required.`)
|
||||
|
||||
if (!opts.namespaceId)
|
||||
throw new Error(`${LOG_TAG}\`namespaceId\` is required.`)
|
||||
|
||||
let headers: CloudflareAuthorizationHeaders
|
||||
|
||||
if ('apiToken' in opts) {
|
||||
headers = { Authorization: `Bearer ${opts.apiToken}` }
|
||||
}
|
||||
else if ('userServiceKey' in opts) {
|
||||
headers = { 'X-Auth-User-Service-Key': opts.userServiceKey }
|
||||
}
|
||||
else if (opts.email && opts.apiKey) {
|
||||
headers = { 'X-Auth-Email': opts.email, 'X-Auth-Key': opts.apiKey }
|
||||
}
|
||||
else {
|
||||
throw new Error(
|
||||
`${LOG_TAG}One of the \`apiToken\`, \`userServiceKey\`, or a combination of \`email\` and \`apiKey\` is required.`,
|
||||
)
|
||||
}
|
||||
|
||||
const apiURL = opts.apiURL || 'https://api.cloudflare.com'
|
||||
const baseURL = `${apiURL}/client/v4/accounts/${opts.accountId}/storage/kv/namespaces/${opts.namespaceId}`
|
||||
const kvFetch = $fetch.create({ baseURL, headers })
|
||||
|
||||
const hasItem = async (key: string) => {
|
||||
try {
|
||||
const res = await kvFetch(`/metadata/${key}`)
|
||||
return res?.success === true
|
||||
}
|
||||
catch (err: any) {
|
||||
if (!err.response)
|
||||
throw err
|
||||
if (err.response.status === 404)
|
||||
return false
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const getItem = async (key: string) => {
|
||||
try {
|
||||
// Cloudflare API returns with `content-type: application/octet-stream`
|
||||
return await kvFetch(`/values/${key}`).then(r => r.text())
|
||||
}
|
||||
catch (err: any) {
|
||||
if (!err.response)
|
||||
throw err
|
||||
if (err.response.status === 404)
|
||||
return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const setItem = async (key: string, value: any) => {
|
||||
return await kvFetch(`/values/${key}`, { method: 'PUT', body: value })
|
||||
}
|
||||
|
||||
const removeItem = async (key: string) => {
|
||||
return await kvFetch(`/values/${key}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
const getKeys = async (base?: string) => {
|
||||
const keys: string[] = []
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (base)
|
||||
params.set('prefix', base)
|
||||
|
||||
const firstPage = await kvFetch('/keys', { params })
|
||||
firstPage.result.forEach(({ name }: { name: string }) => keys.push(name))
|
||||
|
||||
const cursor = firstPage.result_info.cursor
|
||||
if (cursor)
|
||||
params.set('cursor', cursor)
|
||||
|
||||
while (params.has('cursor')) {
|
||||
const pageResult = await kvFetch('/keys', { params: Object.fromEntries(params.entries()) })
|
||||
pageResult.result.forEach(({ name }: { name: string }) => keys.push(name))
|
||||
const pageCursor = pageResult.result_info.cursor
|
||||
if (pageCursor)
|
||||
params.set('cursor', pageCursor)
|
||||
|
||||
else
|
||||
params.delete('cursor')
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
const clear = async () => {
|
||||
const keys: string[] = await getKeys()
|
||||
// Split into chunks of 10000, as the API only allows for 10,000 keys at a time
|
||||
const chunks = keys.reduce((acc, key, i) => {
|
||||
if (i % 10000 === 0)
|
||||
acc.push([])
|
||||
acc[acc.length - 1].push(key)
|
||||
return acc
|
||||
}, [[]] as string[][])
|
||||
// Call bulk delete endpoint with each chunk
|
||||
await Promise.all(chunks.map((chunk) => {
|
||||
return kvFetch('/bulk', {
|
||||
method: 'DELETE',
|
||||
body: { keys: chunk },
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
hasItem,
|
||||
getItem,
|
||||
setItem,
|
||||
removeItem,
|
||||
getKeys,
|
||||
clear,
|
||||
}
|
||||
})
|
|
@ -1,15 +1,11 @@
|
|||
// @ts-expect-error unstorage needs to provide backwards-compatible subpath types
|
||||
import _fs from 'unstorage/drivers/fs'
|
||||
// @ts-expect-error unstorage needs to provide backwards-compatible subpath types
|
||||
import _memory from 'unstorage/drivers/memory'
|
||||
|
||||
import { stringifyQuery } from 'ufo'
|
||||
import fs from 'unstorage/drivers/fs'
|
||||
import memory from 'unstorage/drivers/memory'
|
||||
import kv from 'unstorage/drivers/cloudflare-kv-http'
|
||||
|
||||
import { $fetch } from 'ofetch'
|
||||
import type { Storage } from 'unstorage'
|
||||
|
||||
import cached from '../cache-driver'
|
||||
import kv from '../cloudflare-driver'
|
||||
|
||||
// @ts-expect-error virtual import
|
||||
import { env } from '#build-info'
|
||||
|
@ -19,9 +15,6 @@ import { driver } from '#storage-config'
|
|||
import type { AppInfo } from '~/types'
|
||||
import { APP_NAME } from '~/constants'
|
||||
|
||||
const fs = _fs as typeof import('unstorage/dist/drivers/fs')['default']
|
||||
const memory = _memory as typeof import('unstorage/dist/drivers/memory')['default']
|
||||
|
||||
const storage = useStorage() as Storage
|
||||
|
||||
if (driver === 'fs') {
|
||||
|
@ -41,7 +34,8 @@ else if (driver === 'memory') {
|
|||
}
|
||||
|
||||
export function getRedirectURI(origin: string, server: string) {
|
||||
return `${origin}/api/${server}/oauth?${stringifyQuery({ origin })}`
|
||||
origin = origin.replace(/\?.*$/, '')
|
||||
return `${origin}/api/${server}/oauth/${encodeURIComponent(origin)}`
|
||||
}
|
||||
|
||||
async function fetchAppInfo(origin: string, server: string) {
|
||||
|
@ -58,8 +52,8 @@ async function fetchAppInfo(origin: string, server: string) {
|
|||
}
|
||||
|
||||
export async function getApp(origin: string, server: string) {
|
||||
const host = origin.replace(/^https?:\/\//, '').replace(/[^\w\d]/g, '-')
|
||||
const key = `servers:v2:${server}:${host}.json`.toLowerCase()
|
||||
const host = origin.replace(/^https?:\/\//, '').replace(/[^\w\d]/g, '-').replace(/\?.*$/, '')
|
||||
const key = `servers:v3:${server}:${host}.json`.toLowerCase()
|
||||
|
||||
try {
|
||||
if (await storage.hasItem(key))
|
||||
|
@ -74,13 +68,13 @@ export async function getApp(origin: string, server: string) {
|
|||
}
|
||||
|
||||
export async function deleteApp(server: string) {
|
||||
const keys = (await storage.getKeys(`servers:v2:${server}:`))
|
||||
const keys = (await storage.getKeys(`servers:v3:${server}:`))
|
||||
for (const key of keys)
|
||||
await storage.removeItem(key)
|
||||
}
|
||||
|
||||
export async function listServers() {
|
||||
const keys = await storage.getKeys('servers:v2:')
|
||||
const keys = await storage.getKeys('servers:v3:')
|
||||
const servers = new Set<string>()
|
||||
for await (const key of keys) {
|
||||
const id = key.split(':')[2]
|
||||
|
|
7
types/translation-status.ts
Normal file
7
types/translation-status.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export interface ElkTranslationStatus {
|
||||
total: number
|
||||
locales: Record<string, {
|
||||
percentage: string
|
||||
total: number
|
||||
}>
|
||||
}
|
Loading…
Reference in a new issue