Merge branch 'preview'

This commit is contained in:
ccbikai 2025-03-18 22:08:35 +08:00
commit 2ea7b8f3c9
42 changed files with 1354 additions and 358 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

@ -0,0 +1,39 @@
<script setup>
import { Languages } from 'lucide-vue-next'
const nuxtApp = useNuxtApp()
const i18n = nuxtApp.$i18n
const { setLocale, locales } = useI18n()
const currentLocale = ref(i18n.locale.value)
watch(currentLocale, (newLocale) => {
setLocale(newLocale)
})
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost">
<Languages class="w-5 h-5" />
<span class="sr-only">{{ $t('theme.toggle') }}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
class="min-w-min"
>
<DropdownMenuItem
v-for="locale in locales"
:key="locale.code"
class="cursor-pointer"
@click="setLocale(locale.code)"
>
<span class="mr-1">
{{ locale.emoji }}
</span>
{{ locale.name }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

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

@ -51,14 +51,14 @@ onBeforeMount(() => {
class="flex justify-between px-3 w-full sm:w-48"
>
<div class="flex-1 text-left truncate" :class="selectedLinks.length ? 'text-foreground' : 'text-muted-foreground'">
{{ selectedLinks.length ? selectedLinks.join(', ') : 'Filter Links...' }}
{{ selectedLinks.length ? selectedLinks.join(', ') : $t('dashboard.filter_placeholder') }}
</div>
<ChevronsUpDown class="ml-2 w-4 h-4 opacity-50 shrink-0" />
</Button>
</TriggerTemplate>
<FilterTemplate>
<Command v-model="selectedLinks" multiple>
<CommandInput :placeholder="selectedLinks.length ? selectedLinks.join(', ') : 'Filter Links...'" />
<CommandInput :placeholder="selectedLinks.length ? selectedLinks.join(', ') : $t('dashboard.filter_placeholder')" />
<CommandEmpty>No link found.</CommandEmpty>
<CommandList :class="{ 'max-h-none': !isDesktop }">
<CommandGroup>

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

@ -16,6 +16,7 @@ const props = defineProps({
const emit = defineEmits(['update:link'])
const { t } = useI18n()
const link = ref(props.link)
const dialogOpen = ref(false)
@ -116,10 +117,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,22 +136,22 @@ 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 5 minutes.
{{ $t('links.preview_mode_tip') }}
</p>
<AutoForm
class="px-2 space-y-2 overflow-y-auto"
class="overflow-y-auto px-2 space-y-2"
:schema="EditLinkSchema"
:form="form"
:field-config="fieldConfig"
@ -162,7 +163,7 @@ const { previewMode } = useRuntimeConfig().public
v-if="!isEdit"
class="relative"
>
<div class="absolute right-0 flex space-x-3 top-1">
<div class="flex absolute right-0 top-1 space-x-3">
<Shuffle
class="w-4 h-4 cursor-pointer"
@click="randomSlug"
@ -185,11 +186,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

@ -8,6 +8,24 @@ let cursor = ''
let listComplete = false
let listError = false
const sortBy = ref('az')
const displayedLinks = computed(() => {
const sorted = [...links.value]
switch (sortBy.value) {
case 'newest':
return sorted.sort((a, b) => b.createdAt - a.createdAt)
case 'oldest':
return sorted.sort((a, b) => a.createdAt - b.createdAt)
case 'az':
return sorted.sort((a, b) => a.slug.localeCompare(b.slug))
case 'za':
return sorted.sort((a, b) => b.slug.localeCompare(a.slug))
default:
return sorted
}
})
async function getLinks() {
try {
const data = await useAPI('/api/link/list', {
@ -50,6 +68,7 @@ function updateLinkList(link, type) {
}
else {
links.value.unshift(link)
sortBy.value = 'newest'
}
}
</script>
@ -58,13 +77,16 @@ function updateLinkList(link, type) {
<main class="space-y-6">
<div class="flex flex-col gap-6 sm:gap-2 sm:flex-row sm:justify-between">
<DashboardNav class="flex-1">
<DashboardLinksEditor @update:link="updateLinkList" />
<div class="flex items-center gap-2">
<DashboardLinksEditor @update:link="updateLinkList" />
<DashboardLinksSort v-model:sort-by="sortBy" />
</div>
</DashboardNav>
<LazyDashboardLinksSearch />
</div>
<section class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<DashboardLinksLink
v-for="link in links"
v-for="link in displayedLinks"
:key="link.id"
:link="link"
@update:link="updateLinkList"
@ -80,15 +102,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.value)
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

@ -1,8 +1,6 @@
<script setup>
import { Button } from '#components'
import { Download } from 'lucide-vue-next'
import QRCodeStyling from 'qr-code-styling'
import { ref, watch } from 'vue'
const props = defineProps({
data: {
@ -102,12 +100,14 @@ onMounted(() => {
<div
ref="qrCodeEl"
:data-text="data"
class="bg-white p-1 rounded-lg"
/>
<div class="flex items-center gap-2">
<div class="flex items-center gap-4">
<div class="relative flex items-center">
<div
class="w-8 h-8 rounded-full border cursor-pointer overflow-hidden"
class="w-8 h-8 rounded-full border border-gray-300 dark:border-gray-600 cursor-pointer overflow-hidden"
:style="{ backgroundColor: color }"
title="Change QR code color"
>
<input
v-model="color"
@ -117,9 +117,13 @@ onMounted(() => {
>
</div>
</div>
<Button variant="outline" @click="downloadQRCode">
<Button
variant="outline"
size="sm"
@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

@ -0,0 +1,48 @@
<script setup>
import { ArrowUpDown } from 'lucide-vue-next'
defineProps({
sortBy: {
type: String,
default: 'az',
},
})
const emit = defineEmits(['update:sortBy'])
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline">
<TooltipProvider>
<Tooltip :delay-duration="100">
<TooltipTrigger class="flex items-center">
<ArrowUpDown class="h-4 w-4 sm:mr-2" />
<span class="hidden sm:inline">
{{ $t(`links.sort.${sortBy}`) }}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{{ $t('links.sort.tip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="emit('update:sortBy', 'newest')">
{{ $t('links.sort.newest') }}
</DropdownMenuItem>
<DropdownMenuItem @click="emit('update:sortBy', 'oldest')">
{{ $t('links.sort.oldest') }}
</DropdownMenuItem>
<DropdownMenuItem @click="emit('update:sortBy', 'az')">
{{ $t('links.sort.az') }}
</DropdownMenuItem>
<DropdownMenuItem @click="emit('update:sortBy', 'za')">
{{ $t('links.sort.za') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

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

@ -1,7 +1,6 @@
<script setup>
import { Ellipsis, X } from 'lucide-vue-next'
import { GitHubIcon } from 'vue3-simple-icons'
import SwitchTheme from '../SwitchTheme.vue'
const showMenu = ref(false)
const { title, github } = useAppConfig()
@ -56,28 +55,29 @@ const { title, github } = useAppConfig()
</span>
<span class="mx-2">{{ title }}</span>
</a>
<div class="w-full mx-4" />
<div class="w-auto 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: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"
title="Github"
class="inline-flex items-center w-full px-6 py-3 text-sm font-medium leading-4 text-white bg-gray-900 md:px-3 md:w-auto md:rounded-full hover:bg-gray-800 focus:outline-none md:focus:ring-2 focus:ring-0 focus:ring-offset-2 focus:ring-gray-800"
class="inline-flex items-center w-full px-6 py-3 text-sm font-medium leading-4 text-white bg-gray-900 md:px-3 md:w-auto md:rounded-full hover:bg-gray-800 focus:outline-none md:focus:ring-2 focus:ring-0 focus:ring-offset-2 focus:ring-gray-800 mr-2"
>
<GitHubIcon
class="w-5 h-5 mr-1"
/>
GitHub</a>
<span class="ml-1">
<SwitchTheme />
</span>
<SwitchLanguage />
<SwitchTheme />
</div>
</div>
</div>

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>

View file

@ -51,3 +51,7 @@ Set URL case sensitivity.
## `NUXT_LIST_QUERY_LIMIT`
Set the maximum query data volume for the Metric list.
## `NUXT_DISABLE_BOT_ACCESS_LOG`
Access statistics do not count bot traffic.

View file

@ -46,3 +46,7 @@ Newly generated links will be case-sensitive, treating `MyLink` and `mylink` as
## 7. Why does the Metric list only show the top 500 data entries?
To improve query performance, we have limited the amount of data. If you need to query more data, you can adjust it through `NUXT_LIST_QUERY_LIMIT`.
## 8. I don't want to count bot or crawler traffic
Set `NUXT_DISABLE_BOT_ACCESS_LOG` to `true`.

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: 'en-US',
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: '简体中文',
emoji: '🇨🇳',
},
{
code: 'en-US',
file: 'en-US.json',
name: 'English',
emoji: '🇺🇸',
},
]
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()

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

@ -0,0 +1,169 @@
{
"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)",
"filter_placeholder": "Filter links...",
"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 5 minutes.",
"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",
"sort": {
"newest": "Newest First",
"oldest": "Oldest First",
"az": "Slug A-Z",
"za": "Slug Z-A",
"tip": "Experience: Only for loaded links"
}
}
}

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

@ -0,0 +1,169 @@
{
"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": "(无)",
"filter_placeholder": "过滤链接...",
"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": "预览模式下的链接最多有效5分钟。",
"search_placeholder": "搜索链接...",
"no_results": "未找到链接。",
"group_title": "链接列表",
"copy_success": "复制成功!",
"created_at": "创建时间",
"updated_at": "更新时间",
"expires_at": "过期时间",
"no_more": "没有更多链接",
"load_failed": "加载链接失败,",
"download_qr_code": "下载二维码",
"sort": {
"newest": "最新",
"oldest": "最旧",
"az": "短链(A-Z)",
"za": "短链(Z-A)",
"tip": "体验功能: 仅对已经加载的链接有效"
}
}
}

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 },
@ -27,6 +30,7 @@ export default defineNuxtConfig({
aiPrompt: `You are a URL shortening assistant, please shorten the URL provided by the user into a SLUG. The SLUG information must come from the URL itself, do not make any assumptions. A SLUG is human-readable and should not exceed three words and can be validated using regular expressions {slugRegex} . Only the best one is returned, the format must be JSON reference {"slug": "example-slug"}`,
caseSensitive: false,
listQueryLimit: 500,
disableBotAccessLog: false,
public: {
previewMode: '',
slugDefaultLength: '6',
@ -69,4 +73,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

@ -34,7 +34,7 @@
"pluralize": "^8.0.0",
"qr-code-styling": "1.6.0-rc.1",
"radix-vue": "^1.9.11",
"ua-parser-js": "next",
"ua-parser-js": "^2.0.3",
"vaul-vue": "^0.3.0",
"vee-validate": "^4.15.0",
"virtua": "^0.39.2",
@ -48,6 +48,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

View file

@ -10,7 +10,7 @@ import {
Fetchers,
InApps,
MediaPlayers,
Modules,
Vehicles,
} from 'ua-parser-js/extensions'
import { parseURL } from 'ufo'
@ -69,13 +69,23 @@ export function useAccessLog(event: H3Event) {
const userAgent = getHeader(event, 'user-agent') || ''
const uaInfo = (new UAParser(userAgent, {
browser: [Crawlers.browser || [], CLIs.browser || [], Emails.browser || [], Fetchers.browser || [], InApps.browser || [], MediaPlayers.browser || [], Modules.browser || []].flat(),
browser: [Crawlers.browser || [], CLIs.browser || [], Emails.browser || [], Fetchers.browser || [], InApps.browser || [], MediaPlayers.browser || [], Vehicles.browser || []].flat(),
device: [ExtraDevices.device || []].flat(),
})).getResult()
const { request: { cf } } = event.context.cloudflare
const link = event.context.link || {}
const isBot = cf?.botManagement?.verifiedBot
|| ['crawler', 'fetcher'].includes(uaInfo?.browser?.type || '')
|| ['spider', 'bot'].includes(uaInfo?.browser?.name?.toLowerCase() || '')
const { disableBotAccessLog } = useRuntimeConfig(event)
if (isBot && disableBotAccessLog) {
console.log('bot access log disabled:', userAgent)
return Promise.resolve()
}
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
const countryName = regionNames.of(cf?.country || 'WD') // fallback to "Worldwide"
const accessLogs = {