Merge branch 'main' into userquin/feat-track-scroll-position

# Conflicts:
#	pages/settings/language/index.vue
This commit is contained in:
userquin 2023-02-12 13:33:51 +01:00
commit 97866ebeab
35 changed files with 1431 additions and 797 deletions

View file

@ -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
View file

@ -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

View file

@ -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>

View file

@ -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')),

View file

@ -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>

View file

@ -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"

View file

@ -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]

View file

@ -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

View file

@ -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: [{

View file

@ -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
View file

@ -10,3 +10,4 @@ dist
sw.* sw.*
.env .env
.output .output
translation-status.json

View 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>

View 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>

View 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>

View file

@ -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
View 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
}
}
}

View file

@ -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
View 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>

View file

@ -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"
}, },

View file

@ -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"
}, },

View file

@ -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',
}, },
} }
} }

View file

@ -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',

View file

@ -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()

View file

@ -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": {

View file

@ -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>

View file

@ -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>

File diff suppressed because it is too large Load diff

View 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()

View file

@ -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}`

View file

@ -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)
})

View 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.',
})
}
})

View file

@ -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
} }

View file

@ -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,
}
})

View file

@ -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]

View file

@ -0,0 +1,7 @@
export interface ElkTranslationStatus {
total: number
locales: Record<string, {
percentage: string
total: number
}>
}