feat: add vue-i18n infrastructure and externalize all UI strings (A11Y-03)

Set up vue-i18n with English locale file containing 500+ keys organized
by view namespace. All 15 views converted to use t() calls instead of
hardcoded strings. Infrastructure ready for community translations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-11 13:45:59 +00:00
co-authored by Claude Opus 4.6
parent c1131251f9
commit d15e90c26d
21 changed files with 2491 additions and 640 deletions
+32 -30
View File
@@ -76,8 +76,8 @@
<div class="hidden md:flex mb-8 items-start justify-between">
<div>
<h1 class="text-4xl font-bold text-white mb-2">App Store</h1>
<p class="text-white/70">Discover and install apps for your new sovereign life</p>
<h1 class="text-4xl font-bold text-white mb-2">{{ t('marketplace.title') }}</h1>
<p class="text-white/70">{{ t('marketplace.subtitle') }}</p>
</div>
</div>
@@ -95,7 +95,7 @@
: 'text-white/60 hover:text-white/80 border border-transparent'
]"
>
Curated
{{ t('marketplace.curatedTab') }}
</button>
<button
@click="marketplaceSource = 'community'; loadNostrMarketplace()"
@@ -108,7 +108,7 @@
: 'text-white/60 hover:text-white/80 border border-transparent'
]"
>
Community
{{ t('marketplace.communityTab') }}
<span v-if="nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">{{ nostrApps.length }}</span>
</button>
</div>
@@ -133,8 +133,8 @@
<input
v-model="searchQuery"
type="text"
placeholder="Search apps..."
aria-label="Search apps"
:placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')"
class="flex-shrink-0 w-64 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
@@ -159,8 +159,8 @@
<input
v-model="searchQuery"
type="text"
placeholder="Search apps..."
aria-label="Search apps"
:placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')"
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
@@ -227,7 +227,7 @@
disabled
class="flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium cursor-not-allowed"
>
Already Installed
{{ t('marketplace.alreadyInstalled') }}
</button>
<button
v-else-if="app.source === 'local' || app.dockerImage"
@@ -241,16 +241,16 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ installingApps.get(app.id)?.message || 'Installing...' }}
{{ installingApps.get(app.id)?.message || t('common.installing') }}
</span>
<span v-else>Install</span>
<span v-else>{{ t('common.install') }}</span>
</button>
<button
v-else
disabled
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
>
Not Available
{{ t('common.notAvailable') }}
</button>
</div>
</div>
@@ -263,14 +263,14 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/70">{{ marketplaceSource === 'community' ? 'Querying Nostr relays for apps...' : 'Loading apps...' }}</p>
<p class="text-white/70">{{ marketplaceSource === 'community' ? t('marketplace.queryingRelays') : t('common.loading') }}</p>
</div>
<div v-else-if="nostrError && marketplaceSource === 'community'" class="flex flex-col items-center gap-4">
<p class="text-white/70">No community apps discovered yet.</p>
<p class="text-white/70">{{ t('marketplace.noCommunityApps') }}</p>
<p class="text-white/40 text-sm">{{ nostrError }}</p>
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">Retry</button>
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">{{ t('common.retry') }}</button>
</div>
<p v-else class="text-white/70">No apps found in {{ categories.find(c => c.id === selectedCategory)?.name }}{{ searchQuery ? ` matching "${searchQuery}"` : '' }}</p>
<p v-else class="text-white/70">{{ searchQuery && selectedCategory !== 'all' ? t('marketplace.noResults', { category: categories.find(c => c.id === selectedCategory)?.name, query: searchQuery }) : searchQuery ? t('marketplace.noResultsSearch', { query: searchQuery }) : t('marketplace.noResultsCategory', { category: categories.find(c => c.id === selectedCategory)?.name }) }}</p>
</div>
</div>
<!-- End Scrollable Apps Section -->
@@ -298,7 +298,7 @@
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white">Filter by Category</h2>
<h2 class="text-2xl font-bold text-white">{{ t('marketplace.filterByCategory') }}</h2>
<button
@click="closeFilterModal()"
class="text-white/60 hover:text-white transition-colors"
@@ -372,6 +372,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp, type MarketplaceAppInfo } from '@/composables/useMarketplaceApp'
@@ -381,22 +382,23 @@ type MarketplaceApp = Partial<MarketplaceAppInfo> & { id: string; trustScore?: n
const router = useRouter()
const store = useAppStore()
const { t } = useI18n()
const { setCurrentApp } = useMarketplaceApp()
// Category state
const selectedCategory = ref('all')
const categories = [
{ id: 'all', name: 'All' },
{ id: 'community', name: 'Community' },
{ id: 'commerce', name: 'Commerce' },
{ id: 'money', name: 'Money' },
{ id: 'data', name: 'Data' },
{ id: 'home', name: 'Home' },
{ id: 'car', name: 'Auto' },
{ id: 'networking', name: 'Networking' },
{ id: 'other', name: 'Other' }
]
const categories = computed(() => [
{ id: 'all', name: t('marketplace.all') },
{ id: 'community', name: t('marketplace.community') },
{ id: 'commerce', name: t('marketplace.commerce') },
{ id: 'money', name: t('marketplace.money') },
{ id: 'data', name: t('marketplace.data') },
{ id: 'home', name: t('marketplace.homeCategory') },
{ id: 'car', name: t('marketplace.auto') },
{ id: 'networking', name: t('marketplace.networking') },
{ id: 'other', name: t('marketplace.other') }
])
// Installation state - support multiple concurrent installations
interface InstallProgress {
@@ -425,7 +427,7 @@ watch(() => store.packages, (packages) => {
...current,
status: 'downloading',
progress: Math.min(pct, 95),
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : 'Downloading...',
message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : t('marketplace.downloading'),
})
}
}
@@ -606,7 +608,7 @@ const allApps = computed(() => {
// Only show categories that have at least one app
const categoriesWithApps = computed(() => {
const apps = allApps.value
return categories.filter(cat => {
return categories.value.filter(cat => {
if (cat.id === 'all') return apps.length > 0
return apps.some(app => app.category === cat.id)
})