feat: Add internationalization (i18n) support

Implemented multi-language support for the application:
- Added @nuxtjs/i18n plugin configuration
- Created locale files for English (en-US) and Chinese (zh-CN)
- Updated components to use translation keys
- Added language switcher in header
- Configured VSCode i18n-ally settings
- Prepared translation infrastructure for future language expansions
This commit is contained in:
wudi 2025-03-03 11:21:39 +08:00
parent 31731c5bed
commit e563d55852
36 changed files with 1048 additions and 356 deletions

19
.vscode/settings.json vendored
View file

@ -46,5 +46,22 @@
"scss",
"pcss",
"postcss"
]
],
"i18n-ally.dirStructure": "auto",
"i18n-ally.localesPaths": [
"i18n/locales"
],
"i18n-ally.extract.keygenStyle": "camelCase",
"i18n-ally.keystyle": "nested",
"i18n-ally.enabledParsers": [
"json"
],
"i18n-ally.namespace": true,
"i18n-ally.sortKeys": true,
"i18n-ally.sourceLanguage": "en-US",
"i18n-ally.displayLanguage": "en-US",
"i18n-ally.parsers.typescript.compilerOptions": {
"moduleResolution": "node"
},
"i18n-ally.enabledFrameworks": ["vue"]
}

View file

@ -14,7 +14,7 @@ const colorMode = useColorMode()
<Moon
class="w-5 h-5 transition-all scale-0 dark:scale-100"
/>
<span class="sr-only">Toggle theme</span>
<span class="sr-only">{{ $t('theme.toggle') }}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@ -26,21 +26,21 @@ const colorMode = useColorMode()
@click="colorMode.preference = 'light'"
>
<Sun class="w-4 h-4 mr-1" />
Light
{{ $t('theme.light') }}
</DropdownMenuItem>
<DropdownMenuItem
class="cursor-pointer"
@click="colorMode.preference = 'dark'"
>
<Moon class="w-4 h-4 mr-1" />
Dark
{{ $t('theme.dark') }}
</DropdownMenuItem>
<DropdownMenuItem
class="cursor-pointer"
@click="colorMode.preference = 'system'"
>
<Laptop class="w-4 h-4 mr-1" />
System
{{ $t('theme.system') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -24,7 +24,7 @@ const { title } = useAppConfig()
:as="NuxtLink"
to="/dashboard"
>
Dashboard
{{ $t('dashboard.title') }}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />

View file

@ -44,7 +44,7 @@ onBeforeUnmount(() => {
<Card>
<CardHeader class="flex flex-row justify-between items-center pb-2 space-y-0">
<CardTitle class="text-sm font-medium">
Visits
{{ $t('dashboard.visits') }}
</CardTitle>
<MousePointerClick class="w-4 h-4 text-muted-foreground" />
</CardHeader>
@ -55,7 +55,7 @@ onBeforeUnmount(() => {
<Card>
<CardHeader class="flex flex-row justify-between items-center pb-2 space-y-0">
<CardTitle class="text-sm font-medium">
Visitors
{{ $t('dashboard.visitors') }}
</CardTitle>
<Users class="w-4 h-4 text-muted-foreground" />
</CardHeader>
@ -66,7 +66,7 @@ onBeforeUnmount(() => {
<Card>
<CardHeader class="flex flex-row justify-between items-center pb-2 space-y-0">
<CardTitle class="text-sm font-medium">
Referers
{{ $t('dashboard.referers') }}
</CardTitle>
<Flame class="w-4 h-4 text-muted-foreground" />
</CardHeader>

View file

@ -97,32 +97,32 @@ onBeforeMount(() => {
</SelectTrigger>
<SelectContent>
<SelectItem value="today">
Today
{{ $t('dashboard.date_picker.today') }}
</SelectItem>
<SelectItem value="last-24h">
Last 24 hours
{{ $t('dashboard.date_picker.last_24h') }}
</SelectItem>
<SelectSeparator />
<SelectItem value="this-week">
This week
{{ $t('dashboard.date_picker.this_week') }}
</SelectItem>
<SelectItem value="last-7d">
Last 7 days
{{ $t('dashboard.date_picker.last_7d') }}
</SelectItem>
<SelectSeparator />
<SelectItem value="this-month">
This month
{{ $t('dashboard.date_picker.this_month') }}
</SelectItem>
<SelectItem value="last-30d">
Last 30 days
{{ $t('dashboard.date_picker.last_30d') }}
</SelectItem>
<SelectSeparator />
<SelectItem value="last-90d">
Last 90 days
{{ $t('dashboard.date_picker.last_90d') }}
</SelectItem>
<SelectSeparator />
<SelectItem value="custom">
Custom
{{ $t('dashboard.date_picker.custom') }}
</SelectItem>
</SelectContent>
</Select>
@ -130,7 +130,7 @@ onBeforeMount(() => {
<Dialog v-model:open="openCustomDateRange">
<DialogContent class="w-auto max-w-[95svw] max-h-[95svh] md:max-w-screen-md grid-rows-[auto_minmax(0,1fr)_auto]">
<DialogHeader>
<DialogTitle>Custom Date</DialogTitle>
<DialogTitle>{{ $t('dashboard.date_picker.custom_title') }}</DialogTitle>
</DialogHeader>
<Tabs
default-value="range"
@ -138,10 +138,10 @@ onBeforeMount(() => {
<div class="flex justify-center">
<TabsList>
<TabsTrigger value="date">
Date
{{ $t('dashboard.date_picker.single_date') }}
</TabsTrigger>
<TabsTrigger value="range">
Date Range
{{ $t('dashboard.date_picker.date_range') }}
</TabsTrigger>
</TabsList>
</div>

View file

@ -67,7 +67,7 @@ onBeforeMount(() => {
#left
>
<h3 class="text-xl font-bold leading-10">
{{ link.slug }}'s Stats
{{ link.slug }} {{ $t('dashboard.stats') }}
</h3>
</template>
<DashboardDatePicker @update:date-range="changeDate" />

View file

@ -16,15 +16,15 @@ function logOut() {
</AlertDialogTrigger>
<AlertDialogContent class="max-w-[95svw] max-h-[95svh] md:max-w-lg grid-rows-[auto_minmax(0,1fr)_auto]">
<AlertDialogHeader>
<AlertDialogTitle>LogOut ?</AlertDialogTitle>
<AlertDialogTitle>{{ $t('logout.title') }}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to log out ?
{{ $t('logout.confirm') }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{{ $t('common.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="logOut">
LogOut
{{ $t('logout.action') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -13,10 +13,10 @@ const route = useRoute()
<TabsTrigger
value="/dashboard/links"
>
Links
{{ $t('nav.links') }}
</TabsTrigger>
<TabsTrigger value="/dashboard/analysis">
Analysis
{{ $t('nav.analysis') }}
</TabsTrigger>
</TabsList>
</Tabs>

View file

@ -62,7 +62,7 @@ function formatTime(tick) {
<template>
<Card class="px-0 py-6 md:px-6">
<CardTitle class="px-6 md:px-0">
Views
{{ $t('dashboard.views') }}
</CardTitle>
<component
:is="chart"

View file

@ -29,15 +29,15 @@ async function deleteLink() {
</AlertDialogTrigger>
<AlertDialogContent class="max-w-[95svw] max-h-[95svh] md:max-w-lg grid-rows-[auto_minmax(0,1fr)_auto]">
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogTitle>{{ $t('links.delete_confirm_title') }}</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will really delete your link from servers.
{{ $t('links.delete_confirm_desc') }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{{ $t('common.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="deleteLink">
Continue
{{ $t('common.continue') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -116,10 +116,10 @@ async function onSubmit(formData) {
dialogOpen.value = false
emit('update:link', newLink, isEdit ? 'edit' : 'create')
if (isEdit) {
toast('Link updated successfully')
toast(t('links.update_success'))
}
else {
toast('Link created successfully')
toast(t('links.create_success'))
}
}
@ -135,19 +135,19 @@ const { previewMode } = useRuntimeConfig().public
variant="outline"
@click="randomSlug"
>
Create Link
{{ $t('links.create') }}
</Button>
</slot>
</DialogTrigger>
<DialogContent class="max-w-[95svw] max-h-[95svh] md:max-w-lg grid-rows-[auto_minmax(0,1fr)_auto]">
<DialogHeader>
<DialogTitle>{{ link.id ? 'Edit Link' : 'Create Link' }}</DialogTitle>
<DialogTitle>{{ link.id ? $t('links.edit') : $t('links.create') }}</DialogTitle>
</DialogHeader>
<p
v-if="previewMode"
class="text-sm text-muted-foreground"
>
The preview mode link is valid for up to 24 hours.
{{ $t('links.preview_mode_tip') }}
</p>
<AutoForm
class="px-2 space-y-2 overflow-y-auto"
@ -185,11 +185,11 @@ const { previewMode } = useRuntimeConfig().public
variant="secondary"
class="mt-2 sm:mt-0"
>
Close
{{ $t('common.close') }}
</Button>
</DialogClose>
<Button type="submit">
Save
{{ $t('common.save') }}
</Button>
</DialogFooter>
</AutoForm>

View file

@ -80,15 +80,15 @@ function updateLinkList(link, type) {
v-if="!isLoading && listComplete"
class="flex items-center justify-center text-sm"
>
No more links
{{ $t('links.no_more') }}
</div>
<div
v-if="listError"
class="flex items-center justify-center text-sm"
>
Loading links failed,
{{ $t('links.load_failed') }}
<Button variant="link" @click="getLinks">
Try again
{{ $t('common.try_again') }}
</Button>
</div>
</main>

View file

@ -13,6 +13,7 @@ const props = defineProps({
})
const emit = defineEmits(['update:link'])
const { t } = useI18n()
const editPopoverOpen = ref(false)
const { host, origin } = location
@ -31,6 +32,11 @@ function updateLink(link, type) {
emit('update:link', link, type)
editPopoverOpen.value = false
}
function copyLink() {
copy(shortLink)
toast(t('links.copy_success'))
}
</script>
<template>
@ -69,7 +75,7 @@ function updateLink(link, type) {
<Copy
v-else
class="w-4 h-4 ml-1 shrink-0"
@click.prevent="copy(shortLink);toast('Copy successful!')"
@click.prevent="copyLink"
/>
</div>
@ -134,7 +140,7 @@ function updateLink(link, type) {
<SquarePen
class="w-5 h-5 mr-2"
/>
Edit
{{ $t('common.edit') }}
</div>
</DashboardLinksEditor>
@ -149,7 +155,7 @@ function updateLink(link, type) {
>
<Eraser
class="w-5 h-5 mr-2"
/> Delete
/> {{ $t('common.delete') }}
</div>
</DashboardLinksDelete>
</PopoverContent>

View file

@ -119,7 +119,7 @@ onMounted(() => {
</div>
<Button variant="outline" @click="downloadQRCode">
<Download class="w-4 h-4 mr-2" />
Download QR Code
{{ $t('links.download_qr_code') }}
</Button>
</div>
</div>

View file

@ -59,8 +59,8 @@ onMounted(() => {
size="sm"
class="relative justify-start w-full h-10 bg-background text-muted-foreground sm:w-32 md:w-48"
>
<span class="hidden md:inline-flex">Search Links...</span>
<span class="inline-flex md:hidden">Search</span>
<span class="hidden md:inline-flex">{{ $t('links.search_placeholder') }}</span>
<span class="inline-flex md:hidden">{{ $t('common.search') }}</span>
<kbd class="pointer-events-none absolute right-[0.3rem] top-[0.6rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
<span class="text-xs"></span>K
</kbd>
@ -68,12 +68,12 @@ onMounted(() => {
</TriggerTemplate>
<SearchTemplate>
<Command v-model:search-term="searchTerm" v-model="selectedLink" class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<CommandInput placeholder="Type to search..." />
<CommandList :class="{ 'max-h-none': !isDesktop }">
<CommandInput :placeholder="$t('links.search_placeholder')" />
<CommandList>
<CommandEmpty v-if="searchTerm">
No links found.
{{ $t('links.no_results') }}
</CommandEmpty>
<CommandGroup heading="Links">
<CommandGroup :heading="$t('links.group_title')">
<CommandItem v-for="link in filteredLinks" :key="link.item?.id" class="cursor-pointer" :value="link.item" @select="selectLink(link.item)">
<div class="flex gap-1 w-full">
<div class="inline-flex overflow-hidden flex-1 gap-1 items-center">

View file

@ -1,44 +1,39 @@
<script setup>
import pluralize from 'pluralize'
defineProps({
tabs: {
type: Array,
required: true,
},
rawTabs: {
type: Array,
required: true,
},
})
function type2name(type) {
if (['os'].includes(type))
return type.toUpperCase()
return pluralize(type.replace(type[0], type[0].toUpperCase()))
}
</script>
<template>
<Tabs
:default-value="tabs[0]"
:default-value="rawTabs[0]"
class="flex flex-col"
>
<TabsList class="w-fit">
<TabsTrigger
v-for="tab in tabs"
v-for="(tab, index) in tabs"
:key="tab"
:value="tab"
:value="rawTabs[index]"
>
{{ type2name(tab) }}
{{ tab }}
</TabsTrigger>
</TabsList>
<TabsContent
v-for="tab in tabs"
v-for="(tab, index) in tabs"
:key="tab"
:value="tab"
:value="rawTabs[index]"
class="flex-1"
>
<DashboardMetricsMetric
:type="tab"
:name="type2name(tab)"
:type="rawTabs[index]"
:name="tab"
class="h-full"
/>
</TabsContent>

View file

@ -1,25 +1,50 @@
<script setup>
const { t } = useI18n()
const tabs = {
location: ['country', 'region', 'city'],
referer: ['referer', 'slug'],
time: ['language', 'timezone'],
device: ['device', 'deviceType'],
browser: ['os', 'browser', 'browserType'],
}
const translatedTabs = computed(() => ({
location: tabs.location.map(tab => t(`dashboard.metrics.${tab}`)),
referer: tabs.referer.map(tab => t(`dashboard.metrics.${tab}`)),
time: tabs.time.map(tab => t(`dashboard.metrics.${tab}`)),
device: tabs.device.map(tab => t(`dashboard.metrics.${tab}`)),
browser: tabs.browser.map(tab => t(`dashboard.metrics.${tab}`)),
}))
</script>
<template>
<main class="grid gap-8 lg:grid-cols-12">
<LazyDashboardMetricsLocations class="col-span-1 lg:col-span-8" />
<DashboardMetricsGroup
class="lg:col-span-4"
:tabs="['country', 'region', 'city']"
:tabs="translatedTabs.location"
:raw-tabs="tabs.location"
/>
<DashboardMetricsGroup
class="lg:col-span-6"
:tabs="['referer', 'slug']"
:tabs="translatedTabs.referer"
:raw-tabs="tabs.referer"
/>
<DashboardMetricsGroup
class="lg:col-span-6"
:tabs="['language', 'timezone']"
:tabs="translatedTabs.time"
:raw-tabs="tabs.time"
/>
<DashboardMetricsGroup
class="lg:col-span-6"
:tabs="['device', 'deviceType']"
:tabs="translatedTabs.device"
:raw-tabs="tabs.device"
/>
<DashboardMetricsGroup
class="lg:col-span-6"
:tabs="['os', 'browser', 'browserType']"
:tabs="translatedTabs.browser"
:raw-tabs="tabs.browser"
/>
</main>
</template>

View file

@ -21,12 +21,12 @@ defineProps({
<div
class="h-12 px-4 font-medium text-left align-middle text-muted-foreground "
>
Name
{{ $t('dashboard.name') }}
</div>
<div
class="h-12 px-4 font-medium text-right align-middle text-muted-foreground"
>
Count
{{ $t('dashboard.count') }}
</div>
</div>
<VList

View file

@ -64,7 +64,7 @@ const Tooltip = {
<template>
<Card class="flex flex-col md:h-[500px]">
<CardHeader>
<CardTitle>Locations</CardTitle>
<CardTitle>{{ $t('dashboard.locations') }}</CardTitle>
</CardHeader>
<CardContent class="flex-1 flex [&_[data-radix-aspect-ratio-wrapper]]:flex-1">
<AspectRatio :ratio="65 / 30">

View file

@ -75,9 +75,8 @@ onBeforeUnmount(() => {
<Button
variant="link"
>
<Maximize
class="mr-2 w-4 h-4"
/> DETAILS
<Maximize class="w-4 h-4 mr-2" />
{{ $t('dashboard.details') }}
</Button>
</DialogTrigger>
<DialogContent class="max-w-[95svw] max-h-[95svh] md:max-w-screen-md grid-rows-[auto_minmax(0,1fr)_auto]">

View file

@ -53,7 +53,7 @@ function formatName(name, type) {
v-else
class="w-full truncate"
>
{{ formatName(name, type) || '(None)' }}
{{ formatName(name, type) || $t('dashboard.none') }}
</div>
</TooltipTrigger>
<TooltipContent v-if="name">

View file

@ -3,18 +3,18 @@
class="flex flex-col items-center max-w-4xl p-8 mx-auto my-12 text-center bg-black rounded-lg md:px-20 md:py-20"
>
<h2 class="text-4xl tracking-tight text-white md:text-6xl">
Deployment immediately
{{ $t('home.cta.title') }}
</h2>
<p class="mt-4 text-lg text-slate-400 md:text-xl">
With just a few simple clicks, you can start deploying without any expenses.
{{ $t('home.cta.description') }}
</p>
<div class="flex mt-5">
<HomeLink
href="https://github.com/ccbikai/sink?tab=readme-ov-file#%EF%B8%8F-deployment"
type="inverted"
title="Start Deploy"
:title="$t('home.cta.button')"
>
Start Deploy
{{ $t('home.cta.button') }}
</HomeLink>
</div>
</div>

View file

@ -1,41 +1,36 @@
<script setup>
import { AreaChart, Hourglass, Link, Paintbrush, ServerOff, Sparkles } from 'lucide-vue-next'
const features = ref([
const { t } = useI18n()
const features = computed(() => [
{
title: 'URL Shortening',
description:
'Compress your URLs to their minimal length.',
title: t('home.features.url_shortening.title'),
description: t('home.features.url_shortening.description'),
icon: Link,
},
{
title: 'Analytics',
description:
'Monitor link analytics and gather insightful statistics.',
title: t('home.features.analytics.title'),
description: t('home.features.analytics.description'),
icon: AreaChart,
},
{
title: 'Serverless',
description:
'Deploy without the need for traditional servers.',
title: t('home.features.serverless.title'),
description: t('home.features.serverless.description'),
icon: ServerOff,
},
{
title: 'Customizable Slug',
description:
'Support for personalized slugs and case sensitivity.',
title: t('home.features.customizable_slug.title'),
description: t('home.features.customizable_slug.description'),
icon: Paintbrush,
},
{
title: 'AI Slug',
description:
'Leverage AI to generate slugs.',
title: t('home.features.ai_slug.title'),
description: t('home.features.ai_slug.description'),
icon: Sparkles,
},
{
title: 'Link Expiration',
description:
'Set expiration dates for your links.',
title: t('home.features.link_expiration.title'),
description: t('home.features.link_expiration.description'),
icon: Hourglass,
},
])
@ -45,10 +40,10 @@ const features = ref([
<main class="pt-16 md:py-12">
<div class="md:pb-12">
<h2 class="text-4xl font-bold lg:text-5xl lg:tracking-tight">
Features
{{ $t('home.features.title') }}
</h2>
<p class="my-8 text-lg md:mb-0 text-slate-600">
Simple and sufficient functionality
{{ $t('home.features.subtitle') }}
</p>
</div>

View file

@ -30,7 +30,7 @@ const { title, description, github } = useAppConfig()
<AreaChart
class="w-5 h-5"
/>
Dashboard
{{ $t('dashboard.title') }}
</HomeLink>
<HomeLink
size="lg"

View file

@ -1,7 +1,7 @@
<template>
<div class="mt-16 md:mt-0 md:py-12 ">
<h2 class="font-bold text-center text-slate-600">
Built with awesome technologies
{{ $t('home.logos.title') }}
</h2>
<div class="flex flex-wrap items-center justify-center gap-8 mt-10 md:gap-20">
<img

View file

@ -17,7 +17,7 @@ const { twitter } = useAppConfig()
orientation="vertical"
class="h-4"
/>
<span>Follow me on X(Twitter)</span>
<span>{{ $t('home.twitter.follow') }}</span>
<ArrowRight class="w-4 h-4" />
</a>
</template>

View file

@ -29,7 +29,7 @@ const { title, email, telegram, blog, twitter, mastodon, github } = useAppConfig
title="Email"
class="text-gray-400 hover:text-gray-500"
>
<span class="sr-only">Email</span>
<span class="sr-only">{{ $t('layouts.footer.social.email') }}</span>
<GmailIcon
class="w-6 h-6"
/>
@ -41,7 +41,7 @@ const { title, email, telegram, blog, twitter, mastodon, github } = useAppConfig
title="Telegram"
class="text-gray-400 hover:text-gray-500"
>
<span class="sr-only">Telegram</span>
<span class="sr-only">{{ $t('layouts.footer.social.telegram') }}</span>
<TelegramIcon
class="w-6 h-6"
/>
@ -53,7 +53,7 @@ const { title, email, telegram, blog, twitter, mastodon, github } = useAppConfig
title="Blog"
class="text-gray-400 hover:text-gray-500"
>
<span class="sr-only">Blog</span>
<span class="sr-only">{{ $t('layouts.footer.social.blog') }}</span>
<BloggerIcon
class="w-6 h-6"
/>
@ -66,7 +66,7 @@ const { title, email, telegram, blog, twitter, mastodon, github } = useAppConfig
title="Twitter"
class="text-gray-400 hover:text-gray-500"
>
<span class="sr-only">Twitter</span>
<span class="sr-only">{{ $t('layouts.footer.social.twitter') }}</span>
<XIcon
class="w-6 h-6"
/>
@ -79,7 +79,7 @@ const { title, email, telegram, blog, twitter, mastodon, github } = useAppConfig
title="Mastodon"
class="text-gray-400 hover:text-gray-500"
>
<span class="sr-only">Mastodon</span>
<span class="sr-only">{{ $t('layouts.footer.social.mastodon') }}</span>
<MastodonIcon
class="w-6 h-6"
/>
@ -92,7 +92,7 @@ const { title, email, telegram, blog, twitter, mastodon, github } = useAppConfig
title="GitHub"
class="text-gray-400 hover:text-gray-500"
>
<span class="sr-only">GitHub</span>
<span class="sr-only">{{ $t('layouts.footer.social.github') }}</span>
<GitHubIcon
class="w-6 h-6"
/>

View file

@ -5,6 +5,15 @@ import SwitchTheme from '../SwitchTheme.vue'
const showMenu = ref(false)
const { title, github } = useAppConfig()
const nuxtApp = useNuxtApp()
const i18n = nuxtApp.$i18n
const { setLocale, locales } = useI18n()
const currentLocale = ref(i18n.locale.value)
watch(currentLocale, (newLocale) => {
setLocale(newLocale)
})
</script>
<template>
@ -58,13 +67,13 @@ const { title, github } = useAppConfig()
</a>
<div class="w-full mx-4" />
<div
class="flex flex-col items-start justify-end w-full pt-4 md:items-center md:w-1/3 md:flex-row md:py-0"
class="flex flex-col items-start justify-end w-full pt-4 md:items-center md:w-3/5 md:flex-row md:py-0"
>
<a
class="w-full px-6 py-2 mr-0 text-gray-700 cursor-pointer dark:text-gray-300 md:px-3 md:mr-2 lg:mr-3 md:w-auto"
href="/dashboard"
:title="`${title} Dashboard`"
>Dashboard</a>
>{{ $t('dashboard.title') }}</a>
<a
:href="github"
target="_blank"
@ -75,6 +84,17 @@ const { title, github } = useAppConfig()
class="w-5 h-5 mr-1"
/>
GitHub</a>
<Tabs v-model="currentLocale" class="md:ml-2 lg:ml-3">
<TabsList>
<TabsTrigger
v-for="locale in locales"
:key="locale.code"
:value="locale.code"
>
{{ locale.language }}
</TabsTrigger>
</TabsList>
</Tabs>
<span class="ml-1">
<SwitchTheme />
</span>

View file

@ -3,6 +3,8 @@ import { AlertCircle } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { z } from 'zod'
const { t } = useI18n()
const LoginSchema = z.object({
token: z.string().describe('SiteToken'),
})
@ -25,7 +27,7 @@ async function onSubmit(form) {
}
catch (e) {
console.error(e)
toast.error('Login failed, please try again.', {
toast.error(t('login.failed'), {
description: e.message,
})
}
@ -36,10 +38,10 @@ async function onSubmit(form) {
<Card class="w-full max-w-sm">
<CardHeader>
<CardTitle class="text-2xl">
Login
{{ $t('login.title') }}
</CardTitle>
<CardDescription>
Enter your site token to login.
{{ $t('login.description') }}
</CardDescription>
</CardHeader>
<CardContent class="grid gap-4">
@ -51,13 +53,13 @@ async function onSubmit(form) {
>
<Alert v-if="previewMode">
<AlertCircle class="w-4 h-4" />
<AlertTitle>Tips</AlertTitle>
<AlertTitle>{{ $t('login.tips') }}</AlertTitle>
<AlertDescription>
The site token for preview mode is <code class="font-mono text-green-500">SinkCool</code> .
{{ $t('login.preview_token') }} <code class="font-mono text-green-500">SinkCool</code> .
</AlertDescription>
</Alert>
<Button class="w-full">
Login
{{ $t('login.submit') }}
</Button>
</AutoForm>
</CardContent>

11
i18n/i18n.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { currentLocales } from './i18n'
export default defineI18nConfig(() => {
return {
legacy: false,
availableLocales: currentLocales.map(l => l.code),
fallbackLocale: 'zh-CN',
fallbackWarn: true,
missingWarn: true,
}
})

28
i18n/i18n.ts Normal file
View file

@ -0,0 +1,28 @@
import type { LocaleObject } from '@nuxtjs/i18n'
const locales: LocaleObject[] = [
{
code: 'zh-CN',
file: 'zh-CN.json',
name: '简体中文',
language: '简',
},
{
code: 'en-US',
file: 'en-US.json',
name: 'English',
language: 'En',
},
]
function buildLocales() {
const useLocales = Object.values(locales).reduce((acc, data) => {
acc.push(data)
return acc
}, <LocaleObject[]>[])
return useLocales.sort((a, b) => a.code.localeCompare(b.code))
}
export const currentLocales = buildLocales()

161
i18n/locales/en-US.json Normal file
View file

@ -0,0 +1,161 @@
{
"common": {
"i18n": "Multi Language",
"abbreviations": "En",
"current_language": "English",
"try_again": "Try again",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close",
"save": "Save",
"edit": "Edit",
"delete": "Delete",
"delete_success": "Delete successful!",
"search": "Search"
},
"theme": {
"toggle": "Toggle theme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"login": {
"title": "Login",
"description": "Enter your site token to login",
"tips": "Tips",
"preview_token": "The site token for preview mode is",
"submit": "Login",
"failed": "Login failed, please try again"
},
"logout": {
"title": "LogOut?",
"confirm": "Are you sure you want to log out?",
"action": "LogOut"
},
"layouts": {
"footer": {
"copyright": "Products of HTML.ZONE",
"social": {
"email": "Email",
"telegram": "Telegram",
"blog": "Blog",
"twitter": "Twitter",
"mastodon": "Mastodon",
"github": "GitHub"
}
},
"header": {
"select_language": "Select Language"
}
},
"nav": {
"links": "Links",
"analysis": "Analysis"
},
"home": {
"features": {
"title": "Features",
"subtitle": "Simple and sufficient functionality",
"url_shortening": {
"title": "URL Shortening",
"description": "Compress your URLs to their minimal length"
},
"analytics": {
"title": "Analytics",
"description": "Monitor link analytics and gather insightful statistics"
},
"serverless": {
"title": "Serverless",
"description": "Deploy without the need for traditional servers"
},
"customizable_slug": {
"title": "Customizable Slug",
"description": "Support for personalized slugs and case sensitivity"
},
"ai_slug": {
"title": "AI Slug",
"description": "Leverage AI to generate slugs"
},
"link_expiration": {
"title": "Link Expiration",
"description": "Set expiration dates for your links"
}
},
"cta": {
"title": "Deployment immediately",
"description": "With just a few simple clicks, you can start deploying without any expenses",
"button": "Start Deploy"
},
"logos": {
"title": "Built with awesome technologies"
},
"twitter": {
"follow": "Follow me on X(Twitter)"
}
},
"dashboard": {
"title": "Dashboard",
"visits": "Visits",
"views": "Views",
"visitors": "Visitors",
"referers": "Referers",
"stats": "'s Stats",
"custom_date": "Custom Date",
"date": "Date",
"date_range": "Date Range",
"locations": "Locations",
"details": "Details",
"name": "Name",
"count": "Count",
"loading": "Loading",
"no_data": "No Data",
"none": "(None)",
"date_picker": {
"today": "Today",
"last_24h": "Last 24 hours",
"this_week": "This week",
"last_7d": "Last 7 days",
"this_month": "This month",
"last_30d": "Last 30 days",
"last_90d": "Last 90 days",
"custom": "Custom",
"custom_title": "Custom Date",
"single_date": "Date",
"date_range": "Date Range"
},
"metrics": {
"country": "Country",
"region": "Region",
"city": "City",
"referer": "Referer",
"slug": "Slug",
"language": "Language",
"timezone": "Timezone",
"device": "Device",
"deviceType": "Device Type",
"os": "OS",
"browser": "Browser",
"browserType": "Browser Type"
}
},
"links": {
"create": "Create Link",
"edit": "Edit Link",
"delete_confirm_title": "Are you absolutely sure?",
"delete_confirm_desc": "This action cannot be undone. This will really delete your link from servers.",
"delete_success": "Delete successful!",
"update_success": "Link updated successfully",
"create_success": "Link created successfully",
"preview_mode_tip": "The preview mode link is valid for up to 24 hours.",
"search_placeholder": "Search links...",
"no_results": "No links found.",
"group_title": "Links",
"copy_success": "Copy successful!",
"created_at": "Created At",
"updated_at": "Updated At",
"expires_at": "Expires At",
"no_more": "No more links",
"load_failed": "Loading links failed,",
"download_qr_code": "Download QR Code"
}
}

161
i18n/locales/zh-CN.json Normal file
View file

@ -0,0 +1,161 @@
{
"common": {
"i18n": "多语言",
"abbreviations": "简",
"current_language": "简体中文",
"try_again": "重试",
"cancel": "取消",
"continue": "继续",
"close": "关闭",
"save": "保存",
"edit": "编辑",
"delete": "删除",
"delete_success": "删除成功!",
"search": "搜索"
},
"theme": {
"toggle": "切换主题",
"light": "浅色",
"dark": "深色",
"system": "跟随系统"
},
"login": {
"title": "登录",
"description": "请输入站点令牌以登录",
"tips": "提示",
"preview_token": "预览模式的站点令牌是",
"submit": "登录",
"failed": "登录失败,请重试"
},
"logout": {
"title": "退出登录?",
"confirm": "确定要退出登录吗?",
"action": "退出登录"
},
"layouts": {
"footer": {
"copyright": "产品来自 HTML.ZONE",
"social": {
"email": "邮箱",
"telegram": "Telegram",
"blog": "博客",
"twitter": "Twitter",
"mastodon": "Mastodon",
"github": "GitHub"
}
},
"header": {
"select_language": "选择语言"
}
},
"nav": {
"links": "链接",
"analysis": "分析"
},
"home": {
"features": {
"title": "功能特点",
"subtitle": "简单而实用的功能",
"url_shortening": {
"title": "URL 缩短",
"description": "将您的 URL 压缩到最小长度"
},
"analytics": {
"title": "数据分析",
"description": "监控链接分析并收集有价值的统计数据"
},
"serverless": {
"title": "无服务器",
"description": "无需传统服务器即可部署"
},
"customizable_slug": {
"title": "自定义短链",
"description": "支持个性化短链和大小写敏感"
},
"ai_slug": {
"title": "AI 短链",
"description": "利用 AI 生成短链"
},
"link_expiration": {
"title": "链接过期",
"description": "为您的链接设置过期日期"
}
},
"cta": {
"title": "立即部署",
"description": "只需几个简单的点击,您就可以免费开始部署",
"button": "开始部署"
},
"logos": {
"title": "使用优秀的技术构建"
},
"twitter": {
"follow": "在 X(Twitter) 上关注我"
}
},
"dashboard": {
"title": "仪表盘",
"visits": "访问量",
"views": "浏览量",
"visitors": "访客数",
"referers": "引荐来源",
"stats": "的统计",
"custom_date": "自定义日期",
"date": "日期",
"date_range": "日期范围",
"locations": "地理位置",
"details": "详情",
"name": "名称",
"count": "数量",
"loading": "加载中",
"no_data": "暂无数据",
"none": "(无)",
"date_picker": {
"today": "今天",
"last_24h": "最近24小时",
"this_week": "本周",
"last_7d": "最近7天",
"this_month": "本月",
"last_30d": "最近30天",
"last_90d": "最近90天",
"custom": "自定义",
"custom_title": "自定义日期",
"single_date": "单个日期",
"date_range": "日期范围"
},
"metrics": {
"country": "国家",
"region": "地区",
"city": "城市",
"referer": "来源",
"slug": "短链",
"language": "语言",
"timezone": "时区",
"device": "设备",
"deviceType": "设备类型",
"os": "操作系统",
"browser": "浏览器",
"browserType": "浏览器类型"
}
},
"links": {
"create": "创建链接",
"edit": "编辑链接",
"delete_confirm_title": "确定要删除吗?",
"delete_confirm_desc": "此操作无法撤销。这将从服务器中永久删除您的链接。",
"delete_success": "删除成功!",
"update_success": "链接更新成功",
"create_success": "链接创建成功",
"preview_mode_tip": "预览模式下的链接最多有效24小时。",
"search_placeholder": "搜索链接...",
"no_results": "未找到链接。",
"group_title": "链接列表",
"copy_success": "复制成功!",
"created_at": "创建时间",
"updated_at": "更新时间",
"expires_at": "过期时间",
"no_more": "没有更多链接",
"load_failed": "加载链接失败,",
"download_qr_code": "下载二维码"
}
}

View file

@ -1,3 +1,5 @@
import { currentLocales } from './i18n/i18n'
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
@ -7,6 +9,7 @@ export default defineNuxtConfig({
'@nuxt/eslint',
'@nuxtjs/tailwindcss',
'@nuxtjs/color-mode',
'@nuxtjs/i18n',
],
devtools: { enabled: true },
@ -69,4 +72,21 @@ export default defineNuxtConfig({
standalone: false,
},
},
i18n: {
locales: currentLocales,
compilation: {
strictMessage: false,
escapeHtml: true,
},
lazy: true,
strategy: 'no_prefix',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'sink_i18n_redirected',
redirectOn: 'root',
},
baseUrl: '/',
defaultLocale: 'en-US',
},
})

View file

@ -38,6 +38,7 @@
"vaul-vue": "^0.3.0",
"vee-validate": "^4.15.0",
"virtua": "^0.39.2",
"vue": "^3.5.13",
"vue-sonner": "^1.3.0",
"vue3-simple-icons": "^13.2.0",
"zod": "^3.24.1"
@ -48,6 +49,7 @@
"@nuxt/eslint-config": "^0.7.4",
"@nuxthub/core": "0.7.11",
"@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/i18n": "^9.0.0",
"@nuxtjs/tailwindcss": "^6.12.2",
"@vueuse/integrations": "^12.2.0",
"class-variance-authority": "^0.7.1",

File diff suppressed because it is too large Load diff