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.crt
|
||||||
https-dev-config/localhost.key
|
https-dev-config/localhost.key
|
||||||
Dockerfile
|
Dockerfile
|
||||||
|
elk-translation-status.json
|
||||||
|
docs/translation-status.json
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -9,6 +9,7 @@ dist
|
||||||
.vite-inspect
|
.vite-inspect
|
||||||
.netlify/
|
.netlify/
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
elk-translation-status.json
|
||||||
|
|
||||||
public/shiki
|
public/shiki
|
||||||
public/emojis
|
public/emojis
|
||||||
|
|
|
@ -10,7 +10,7 @@ const emit = defineEmits<{
|
||||||
<div i-ri:close-line />
|
<div i-ri:close-line />
|
||||||
</button>
|
</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>
|
<h1 mxa text-4xl mb4>
|
||||||
{{ $t('help.title') }}
|
{{ $t('help.title') }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
|
@ -27,7 +27,7 @@ const emit = defineEmits<{
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const draftState = useDraft(draftKey, initial)
|
const draftState = useDraft(draftKey, initial)
|
||||||
const { draft } = $(draftState)
|
const { draft, isEmpty } = $(draftState)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isExceedingAttachmentLimit, isUploading, failedAttachments, isOverDropZone,
|
isExceedingAttachmentLimit, isUploading, failedAttachments, isOverDropZone,
|
||||||
|
@ -48,6 +48,8 @@ const { editor } = useTiptap({
|
||||||
set: (newVal) => {
|
set: (newVal) => {
|
||||||
draft.params.status = newVal
|
draft.params.status = newVal
|
||||||
draft.lastUpdated = Date.now()
|
draft.lastUpdated = Date.now()
|
||||||
|
if (isEmpty)
|
||||||
|
clearEmptyDrafts()
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
placeholder: computed(() => placeholder ?? draft.params.inReplyToId ? t('placeholder.replying') : t('placeholder.default_1')),
|
placeholder: computed(() => placeholder ?? draft.params.inReplyToId ? t('placeholder.replying') : t('placeholder.default_1')),
|
||||||
|
|
|
@ -96,7 +96,7 @@ onClickOutside(input, () => {
|
||||||
<template>
|
<template>
|
||||||
<form text-center justify-center items-center max-w-150 py6 flex="~ col gap-3" @submit.prevent="oauth">
|
<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>
|
<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>
|
<div text-3xl>
|
||||||
{{ $t('action.sign_in') }}
|
{{ $t('action.sign_in') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -10,7 +10,7 @@ const { busy, oauth, singleInstanceServer } = useSignIn()
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
</p>
|
</p>
|
||||||
<p text-sm text-secondary>
|
<p text-sm text-secondary>
|
||||||
{{ $t('user.sign_in_desc') }}
|
{{ $t(singleInstanceServer ? 'user.single_instance_sign_in_desc' : 'user.sign_in_desc') }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
v-if="singleInstanceServer"
|
v-if="singleInstanceServer"
|
||||||
|
|
|
@ -30,7 +30,7 @@ export function getDefaultDraft(options: Partial<Mutable<mastodon.v1.CreateStatu
|
||||||
params: {
|
params: {
|
||||||
status: status || '',
|
status: status || '',
|
||||||
inReplyToId,
|
inReplyToId,
|
||||||
visibility: visibility || 'public',
|
visibility: currentUser.value?.account.source.privacy || visibility || 'public',
|
||||||
sensitive: sensitive ?? false,
|
sensitive: sensitive ?? false,
|
||||||
spoilerText: spoilerText || '',
|
spoilerText: spoilerText || '',
|
||||||
language: language || '', // auto inferred from current language on posting
|
language: language || '', // auto inferred from current language on posting
|
||||||
|
@ -141,7 +141,7 @@ export function directMessageUser(account: mastodon.v1.Account) {
|
||||||
|
|
||||||
export function clearEmptyDrafts() {
|
export function clearEmptyDrafts() {
|
||||||
for (const key in currentUserDrafts.value) {
|
for (const key in currentUserDrafts.value) {
|
||||||
if (builtinDraftKeys.includes(key))
|
if (builtinDraftKeys.includes(key) && !isEmptyDraft(currentUserDrafts.value[key]))
|
||||||
continue
|
continue
|
||||||
if (!currentUserDrafts.value[key].params || isEmptyDraft(currentUserDrafts.value[key]))
|
if (!currentUserDrafts.value[key].params || isEmptyDraft(currentUserDrafts.value[key]))
|
||||||
delete currentUserDrafts.value[key]
|
delete currentUserDrafts.value[key]
|
||||||
|
|
|
@ -27,9 +27,11 @@ export function emojisArrayToObject(emojis: mastodon.v1.CustomEmoji[]) {
|
||||||
|
|
||||||
export function noop() {}
|
export function noop() {}
|
||||||
|
|
||||||
export const useIsMac = () => computed(() =>
|
export const useIsMac = () => {
|
||||||
useRequestHeaders(['user-agent'])['user-agent']?.includes('Macintosh')
|
const headers = useRequestHeaders(['user-agent'])
|
||||||
|
return computed(() => headers['user-agent']?.includes('Macintosh')
|
||||||
?? navigator?.platform?.includes('Mac') ?? false)
|
?? navigator?.platform?.includes('Mac') ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
export const isEmptyObject = (object: Object) => Object.keys(object).length === 0
|
export const isEmptyObject = (object: Object) => Object.keys(object).length === 0
|
||||||
|
|
||||||
|
|
|
@ -8,14 +8,14 @@ export function setupPageHeader() {
|
||||||
const enablePinchToZoom = usePreferences('enablePinchToZoom')
|
const enablePinchToZoom = usePreferences('enablePinchToZoom')
|
||||||
|
|
||||||
const localeMap = (locales.value as LocaleObject[]).reduce((acc, l) => {
|
const localeMap = (locales.value as LocaleObject[]).reduce((acc, l) => {
|
||||||
acc[l.code!] = l.dir ?? 'auto'
|
acc[l.code!] = l.dir ?? 'ltr'
|
||||||
return acc
|
return acc
|
||||||
}, {} as Record<string, Directions>)
|
}, {} as Record<string, Directions>)
|
||||||
|
|
||||||
useHeadFixed({
|
useHeadFixed({
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
lang: () => locale.value,
|
lang: () => locale.value,
|
||||||
dir: () => localeMap[locale.value] ?? 'auto',
|
dir: () => localeMap[locale.value] ?? 'ltr',
|
||||||
class: () => enablePinchToZoom.value ? ['enable-pinch-to-zoom'] : [],
|
class: () => enablePinchToZoom.value ? ['enable-pinch-to-zoom'] : [],
|
||||||
},
|
},
|
||||||
meta: [{
|
meta: [{
|
||||||
|
|
|
@ -198,7 +198,7 @@ const buildLocales = () => {
|
||||||
return useLocales.sort((a, b) => a.code.localeCompare(b.code))
|
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 = Object.values(currentLocales).reduce((acc, data) => {
|
||||||
const dateTimeFormats = data.dateTimeFormats
|
const dateTimeFormats = data.dateTimeFormats
|
||||||
|
|
1
docs/.gitignore
vendored
1
docs/.gitignore
vendored
|
@ -10,3 +10,4 @@ dist
|
||||||
sw.*
|
sw.*
|
||||||
.env
|
.env
|
||||||
.output
|
.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
|
nr test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Translation status
|
||||||
|
|
||||||
|
<TranslationState />
|
||||||
|
|
||||||
# Stack
|
# Stack
|
||||||
|
|
||||||
- [Vite](https://vitejs.dev/) - Next Generation Frontend Tooling
|
- [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"
|
"preview": "nuxi preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt-themes/docus": "^1.6.1",
|
"@nuxt-themes/docus": "^1.8.1",
|
||||||
"nuxt": "^3.1.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": {
|
"language": {
|
||||||
"display_language": "Display Language",
|
"display_language": "Display Language",
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
|
"status": "Translation status: {0}/{1} ({2}%)",
|
||||||
"translations": {
|
"translations": {
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"choose_language": "Choose language",
|
"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_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_in_notice_title": "Viewing {0} public data",
|
||||||
"sign_out_account": "Sign out {0}",
|
"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_no_account": "If you don't have a Mastodon account yet, {0}.",
|
||||||
"tip_register_account": "pick your server and register one"
|
"tip_register_account": "pick your server and register one"
|
||||||
},
|
},
|
||||||
|
|
|
@ -337,6 +337,7 @@
|
||||||
"language": {
|
"language": {
|
||||||
"display_language": "Idioma de pantalla",
|
"display_language": "Idioma de pantalla",
|
||||||
"label": "Idioma",
|
"label": "Idioma",
|
||||||
|
"status": "Estado traducción: {0}/{1} ({2}%)",
|
||||||
"translations": {
|
"translations": {
|
||||||
"add": "Agregar",
|
"add": "Agregar",
|
||||||
"choose_language": "Seleccionar idioma",
|
"choose_language": "Seleccionar idioma",
|
||||||
|
@ -405,16 +406,19 @@
|
||||||
"github_cards": "Tarjetas GitHub",
|
"github_cards": "Tarjetas GitHub",
|
||||||
"grayscale_mode": "Modo escala de grises",
|
"grayscale_mode": "Modo escala de grises",
|
||||||
"hide_account_hover_card": "Ocultar tarjeta flotante de cuenta",
|
"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_boost_count": "Ocultar contador de retoots",
|
||||||
"hide_favorite_count": "Ocultar número de publicaciones favoritas",
|
"hide_favorite_count": "Ocultar número de publicaciones favoritas",
|
||||||
"hide_follower_count": "Ocultar número de seguidores",
|
"hide_follower_count": "Ocultar número de seguidores",
|
||||||
"hide_reply_count": "Ocultar número de respuestas",
|
"hide_reply_count": "Ocultar número de respuestas",
|
||||||
"hide_translation": "Ocultar traducción",
|
"hide_translation": "Ocultar traducción",
|
||||||
"hide_username_emojis": "Ocultar emojis en el nombre de usuario",
|
"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",
|
"label": "Preferencias",
|
||||||
"title": "Funcionalidades experimentales",
|
"title": "Funcionalidades experimentales",
|
||||||
"user_picker": "Selector de usuarios",
|
"user_picker": "Selector de usuarios",
|
||||||
"virtual_scroll": "Desplazamiento virtual"
|
"virtual_scroll": "Desplazamiento virtual",
|
||||||
|
"wellbeing": "Bienestar"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"appearance": {
|
"appearance": {
|
||||||
|
@ -463,8 +467,10 @@
|
||||||
"filter_removed_phrase": "Eliminado por filtrado",
|
"filter_removed_phrase": "Eliminado por filtrado",
|
||||||
"filter_show_anyway": "Mostrar de todas formas",
|
"filter_show_anyway": "Mostrar de todas formas",
|
||||||
"img_alt": {
|
"img_alt": {
|
||||||
|
"ALT": "ALT",
|
||||||
"desc": "Descripción",
|
"desc": "Descripción",
|
||||||
"dismiss": "Descartar"
|
"dismiss": "Descartar",
|
||||||
|
"read": "Leer la descripción de la imagen {0}"
|
||||||
},
|
},
|
||||||
"poll": {
|
"poll": {
|
||||||
"count": "{0} votos|{0} voto|{0} votos",
|
"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_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_in_notice_title": "Viendo información pública de {0}",
|
||||||
"sign_out_account": "Cerrar sesión {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_no_account": "Si aún no tienes una cuenta Mastodon, {0}.",
|
||||||
"tip_register_account": "selecciona tu servidor y registrate"
|
"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]) => {
|
Object.keys(webmanifests!).map(wm => [wm, `manifest-${wm}.webmanifest`]).forEach(([wm, fileName]) => {
|
||||||
bundle[fileName] = {
|
bundle[fileName] = {
|
||||||
isAsset: true,
|
needsCodeReference: false,
|
||||||
type: 'asset',
|
type: 'asset',
|
||||||
name: undefined,
|
name: undefined,
|
||||||
source: generateManifest(wm),
|
source: generateManifest(wm),
|
||||||
|
@ -79,6 +79,7 @@ export default defineNuxtModule<VitePWANuxtOptions>({
|
||||||
if (entry) {
|
if (entry) {
|
||||||
res.statusCode = 200
|
res.statusCode = 200
|
||||||
res.setHeader('Content-Type', 'application/manifest+json')
|
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.write(JSON.stringify(entry), 'utf-8')
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
|
@ -135,15 +136,22 @@ export default defineNuxtModule<VitePWANuxtOptions>({
|
||||||
else {
|
else {
|
||||||
nuxt.hook('nitro:config', async (nitroConfig) => {
|
nuxt.hook('nitro:config', async (nitroConfig) => {
|
||||||
nitroConfig.routeRules = nitroConfig.routeRules || {}
|
nitroConfig.routeRules = nitroConfig.routeRules || {}
|
||||||
|
nitroConfig.routeRules!['/sw.js'] = {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=0, must-revalidate',
|
||||||
|
},
|
||||||
|
}
|
||||||
for (const locale of pwaLocales) {
|
for (const locale of pwaLocales) {
|
||||||
nitroConfig.routeRules![`/manifest-${locale.code}.webmanifest`] = {
|
nitroConfig.routeRules![`/manifest-${locale.code}.webmanifest`] = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/manifest+json',
|
'Content-Type': 'application/manifest+json',
|
||||||
|
'Cache-Control': 'public, max-age=0, must-revalidate',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
nitroConfig.routeRules![`/manifest-${locale.code}-dark.webmanifest`] = {
|
nitroConfig.routeRules![`/manifest-${locale.code}-dark.webmanifest`] = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/manifest+json',
|
'Content-Type': 'application/manifest+json',
|
||||||
|
'Cache-Control': 'public, max-age=0, must-revalidate',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import {
|
||||||
const handlers = [
|
const handlers = [
|
||||||
{
|
{
|
||||||
route: '/api/:server/oauth',
|
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',
|
route: '/api/:server/login',
|
||||||
|
|
|
@ -35,7 +35,6 @@ export default defineNuxtConfig({
|
||||||
],
|
],
|
||||||
experimental: {
|
experimental: {
|
||||||
payloadExtraction: false,
|
payloadExtraction: false,
|
||||||
reactivityTransform: true,
|
|
||||||
inlineSSRStyles: false,
|
inlineSSRStyles: false,
|
||||||
},
|
},
|
||||||
css: [
|
css: [
|
||||||
|
@ -72,15 +71,6 @@ export default defineNuxtConfig({
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: 'esnext',
|
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: {
|
postcss: {
|
||||||
|
@ -123,6 +113,7 @@ export default defineNuxtConfig({
|
||||||
'/manifest.webmanifest': {
|
'/manifest.webmanifest': {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/manifest+json',
|
'Content-Type': 'application/manifest+json',
|
||||||
|
'Cache-Control': 'public, max-age=0, must-revalidate',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -139,7 +130,7 @@ export default defineNuxtConfig({
|
||||||
crawlLinks: true,
|
crawlLinks: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sourcemap: !isDevelopment,
|
sourcemap: isDevelopment,
|
||||||
hooks: {
|
hooks: {
|
||||||
'nitro:config': function (config) {
|
'nitro:config': function (config) {
|
||||||
const nuxt = useNuxt()
|
const nuxt = useNuxt()
|
||||||
|
|
15
package.json
15
package.json
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@elk-zone/elk",
|
"name": "@elk-zone/elk",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"packageManager": "pnpm@7.9.0",
|
"packageManager": "pnpm@7.9.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://elk.zone/",
|
"homepage": "https://elk.zone/",
|
||||||
|
@ -23,7 +23,8 @@
|
||||||
"test:typecheck": "stale-dep && vue-tsc --noEmit && vue-tsc --noEmit --project service-worker/tsconfig.json",
|
"test:typecheck": "stale-dep && vue-tsc --noEmit && vue-tsc --noEmit --project service-worker/tsconfig.json",
|
||||||
"test": "nr test:unit",
|
"test": "nr test:unit",
|
||||||
"update:team:avatars": "esno scripts/avatars.ts",
|
"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"
|
"release": "stale-dep && bumpp && esno scripts/release.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -38,6 +39,7 @@
|
||||||
"@iconify-json/ri": "^1.1.4",
|
"@iconify-json/ri": "^1.1.4",
|
||||||
"@iconify-json/twemoji": "^1.1.10",
|
"@iconify-json/twemoji": "^1.1.10",
|
||||||
"@iconify/utils": "^2.0.12",
|
"@iconify/utils": "^2.0.12",
|
||||||
|
"@nuxt/devtools": "^0.1.0",
|
||||||
"@nuxtjs/color-mode": "^3.2.0",
|
"@nuxtjs/color-mode": "^3.2.0",
|
||||||
"@nuxtjs/i18n": "8.0.0-beta.9",
|
"@nuxtjs/i18n": "8.0.0-beta.9",
|
||||||
"@pinia/nuxt": "^0.4.6",
|
"@pinia/nuxt": "^0.4.6",
|
||||||
|
@ -106,9 +108,9 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^0.34.1",
|
"@antfu/eslint-config": "^0.34.1",
|
||||||
"@antfu/ni": "^0.19.0",
|
"@antfu/ni": "^0.19.0",
|
||||||
"@nuxt/devtools": "^0.1.0",
|
|
||||||
"@types/chroma-js": "^2.1.4",
|
"@types/chroma-js": "^2.1.4",
|
||||||
"@types/file-saver": "^2.0.5",
|
"@types/file-saver": "^2.0.5",
|
||||||
|
"@types/flat": "^5.0.2",
|
||||||
"@types/fnando__sparkline": "^0.3.4",
|
"@types/fnando__sparkline": "^0.3.4",
|
||||||
"@types/fs-extra": "^11.0.1",
|
"@types/fs-extra": "^11.0.1",
|
||||||
"@types/js-yaml": "^4.0.5",
|
"@types/js-yaml": "^4.0.5",
|
||||||
|
@ -117,9 +119,10 @@
|
||||||
"bumpp": "^8.2.1",
|
"bumpp": "^8.2.1",
|
||||||
"eslint": "^8.32.0",
|
"eslint": "^8.32.0",
|
||||||
"esno": "^0.16.3",
|
"esno": "^0.16.3",
|
||||||
|
"flat": "^5.0.2",
|
||||||
"fs-extra": "^11.1.0",
|
"fs-extra": "^11.1.0",
|
||||||
"lint-staged": "^13.1.0",
|
"lint-staged": "^13.1.0",
|
||||||
"nuxt": "3.1.1",
|
"nuxt": "3.2.0",
|
||||||
"prettier": "^2.8.3",
|
"prettier": "^2.8.3",
|
||||||
"simple-git-hooks": "^2.8.1",
|
"simple-git-hooks": "^2.8.1",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
|
@ -149,9 +152,7 @@
|
||||||
"@tiptap/extension-paragraph": "2.0.0-beta.204",
|
"@tiptap/extension-paragraph": "2.0.0-beta.204",
|
||||||
"@tiptap/extension-strike": "2.0.0-beta.204",
|
"@tiptap/extension-strike": "2.0.0-beta.204",
|
||||||
"@tiptap/extension-text": "2.0.0-beta.204",
|
"@tiptap/extension-text": "2.0.0-beta.204",
|
||||||
"vitest>vite": "^3.2.5",
|
"vue": "3.2.45"
|
||||||
"@nuxt/kit": "^3.1.2",
|
|
||||||
"@nuxt/schema": "^3.1.2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"simple-git-hooks": {
|
"simple-git-hooks": {
|
||||||
|
|
|
@ -29,7 +29,7 @@ const handleShowCommit = () => {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div flex="~ col gap4" w-full items-center justify-center my5>
|
<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>
|
<p text-lg>
|
||||||
{{ $t('app_desc_short') }}
|
{{ $t('app_desc_short') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -1,13 +1,21 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { ElkTranslationStatus } from '~/types/translation-status'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
noScrollTrack: true,
|
noScrollTrack: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
|
const translationStatus: ElkTranslationStatus = await import('~/elk-translation-status.json').then(m => m.default)
|
||||||
|
|
||||||
useHeadFixed({
|
useHeadFixed({
|
||||||
title: () => `${t('settings.language.label')} | ${t('nav.settings')}`,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -19,7 +27,10 @@ useHeadFixed({
|
||||||
</template>
|
</template>
|
||||||
<div p6>
|
<div p6>
|
||||||
<label space-y-2>
|
<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 />
|
<SettingsLanguage select-settings />
|
||||||
</label>
|
</label>
|
||||||
<h2 py4 mt2 font-bold text-xl flex="~ gap-1" items-center>
|
<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,
|
client_id: app.client_id,
|
||||||
force_login: force_login === true ? 'true' : 'false',
|
force_login: force_login === true ? 'true' : 'false',
|
||||||
scope: 'read write follow push',
|
scope: 'read write follow push',
|
||||||
redirect_uri: getRedirectURI(origin, server),
|
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
lang,
|
lang,
|
||||||
|
redirect_uri: getRedirectURI(origin, server),
|
||||||
})
|
})
|
||||||
|
|
||||||
return `https://${server}/oauth/authorize?${query}`
|
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'
|
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'
|
import { defineDriver } from 'unstorage'
|
||||||
|
|
||||||
const memory = _memory as typeof import('unstorage/dist/drivers/memory')['default']
|
|
||||||
|
|
||||||
export interface CacheDriverOptions {
|
export interface CacheDriverOptions {
|
||||||
driver: Driver
|
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'
|
||||||
import _fs from 'unstorage/drivers/fs'
|
import memory from 'unstorage/drivers/memory'
|
||||||
// @ts-expect-error unstorage needs to provide backwards-compatible subpath types
|
import kv from 'unstorage/drivers/cloudflare-kv-http'
|
||||||
import _memory from 'unstorage/drivers/memory'
|
|
||||||
|
|
||||||
import { stringifyQuery } from 'ufo'
|
|
||||||
|
|
||||||
import { $fetch } from 'ofetch'
|
import { $fetch } from 'ofetch'
|
||||||
import type { Storage } from 'unstorage'
|
import type { Storage } from 'unstorage'
|
||||||
|
|
||||||
import cached from '../cache-driver'
|
import cached from '../cache-driver'
|
||||||
import kv from '../cloudflare-driver'
|
|
||||||
|
|
||||||
// @ts-expect-error virtual import
|
// @ts-expect-error virtual import
|
||||||
import { env } from '#build-info'
|
import { env } from '#build-info'
|
||||||
|
@ -19,9 +15,6 @@ import { driver } from '#storage-config'
|
||||||
import type { AppInfo } from '~/types'
|
import type { AppInfo } from '~/types'
|
||||||
import { APP_NAME } from '~/constants'
|
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
|
const storage = useStorage() as Storage
|
||||||
|
|
||||||
if (driver === 'fs') {
|
if (driver === 'fs') {
|
||||||
|
@ -41,7 +34,8 @@ else if (driver === 'memory') {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRedirectURI(origin: string, server: string) {
|
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) {
|
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) {
|
export async function getApp(origin: string, server: string) {
|
||||||
const host = origin.replace(/^https?:\/\//, '').replace(/[^\w\d]/g, '-')
|
const host = origin.replace(/^https?:\/\//, '').replace(/[^\w\d]/g, '-').replace(/\?.*$/, '')
|
||||||
const key = `servers:v2:${server}:${host}.json`.toLowerCase()
|
const key = `servers:v3:${server}:${host}.json`.toLowerCase()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (await storage.hasItem(key))
|
if (await storage.hasItem(key))
|
||||||
|
@ -74,13 +68,13 @@ export async function getApp(origin: string, server: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteApp(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)
|
for (const key of keys)
|
||||||
await storage.removeItem(key)
|
await storage.removeItem(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listServers() {
|
export async function listServers() {
|
||||||
const keys = await storage.getKeys('servers:v2:')
|
const keys = await storage.getKeys('servers:v3:')
|
||||||
const servers = new Set<string>()
|
const servers = new Set<string>()
|
||||||
for await (const key of keys) {
|
for await (const key of keys) {
|
||||||
const id = key.split(':')[2]
|
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