Compare commits

..

No commits in common. "master" and "v0.1.0" have entirely different histories.

55 changed files with 3666 additions and 3450 deletions

View File

@ -2,11 +2,9 @@ NUXT_PUBLIC_PREVIEW_MODE=true
NUXT_PUBLIC_SLUG_DEFAULT_LENGTH=5
NUXT_SITE_TOKEN=SinkCool
NUXT_REDIRECT_STATUS_CODE=308
NUXT_LINK_CACHE_TTL=60
NUXT_REDIRECT_WITH_QUERY=false
NUXT_HOME_URL="https://sink.cool"
NUXT_CF_ACCOUNT_ID=123456
NUXT_CF_API_TOKEN=CloudflareAPIToken
NUXT_DATASET=sink
NUXT_DATASET=sink_v0
NUXT_AI_MODEL="@cf/meta/llama-3-8b-instruct"
NUXT_AI_PROMPT="You are a URL shortening assistant......"

2
.github/FUNDING.yml vendored
View File

@ -1,2 +0,0 @@
github: ccbikai
buy_me_a_coffee: ccbikai

1
.gitignore vendored
View File

@ -25,4 +25,3 @@ logs
.wrangler
site
cache
public/world.json

View File

@ -1 +1 @@
v20.11
v20

View File

@ -1,30 +1,6 @@
# ⚡ Sink
**A Simple / Speedy / Secure Link Shortener with Analytics, 100% run on Cloudflare.**
<a href="https://trendshift.io/repositories/10421" target="_blank">
<img
src="https://trendshift.io/api/badge/repositories/10421"
alt="ccbikai/Sink | Trendshift"
style="width: 250px; height: 55px;"
width="250"
height="55"
/>
</a>
<a href="https://news.ycombinator.com/item?id=40843683">
<img
src="https://hackernews-badge.vercel.app/api?id=40843683"
alt="Featured on Hacker News"
style="width: 250px; height: 55px;"
width="250"
height="55"
/>
</a>
![Cloudflare](https://img.shields.io/badge/Cloudflare-F69652?style=flat&logo=cloudflare&logoColor=white)
![Nuxt](https://img.shields.io/badge/Nuxt-00DC82?style=flat&logo=nuxtdotjs&logoColor=white)
![Tailwind CSS](https://img.shields.io/badge/Tailwind%20CSS-06B6D4?style=flat&logo=tailwindcss&logoColor=white)
![shadcn/ui](https://img.shields.io/badge/shadcn/ui-000000?style=flat&logo=shadcnui&logoColor=white)
**A Simple / Speedy / Secrue Link Shortener with Analytics, 100% run on Cloudflare.**
![Hero](./public/image.png)
@ -97,14 +73,6 @@ We welcome your contributions and PRs.
[Configuration Docs](./docs/configuration.md)
## 🔌 API
[API Docs](./docs/api.md)
## 🙋🏻 FAQs
[FAQs](./docs/faqs.md)
## 💖 Credits
1. [**Cloudflare**](https://www.cloudflare.com/)
@ -113,5 +81,5 @@ We welcome your contributions and PRs.
## ☕ Sponsor
1. [Follow Me on X(Twitter)](https://x.com/0xKaiBi).
1. [Follow Me on X(Twitter)](https://x.com/ccbikai).
2. [Become a sponsor to on GitHub](https://github.com/sponsors/ccbikai).

View File

@ -1,6 +1,6 @@
export default defineAppConfig({
title: 'Sink',
description: 'A Simple / Speedy / Secure Link Shortener with Analytics, 100% run on Cloudflare.',
description: 'A Simple / Speedy / Secrue Link Shortener with Analytics, 100% run on Cloudflare.',
image: 'https://sink.cool/banner.png',
previewTTL: 24 * 3600, // 24h
slugRegex: /^[a-z0-9]+(?:-[a-z0-9]+)*$/i,

View File

@ -1,9 +1,8 @@
<script setup>
const { title, description, image } = useAppConfig()
useSeoMeta({
title: `${title} - ${description}`,
title,
description,
ogType: 'website',
ogTitle: title,
ogDescription: description,
ogImage: image,
@ -21,7 +20,7 @@ useHead({
{
rel: 'icon',
type: 'image/png',
href: '/icon-192.png',
href: '/sink.png',
},
],
})

File diff suppressed because one or more lines are too long

View File

@ -1,47 +0,0 @@
<script setup>
import { Laptop, Moon, Sun } from 'lucide-vue-next'
const colorMode = useColorMode()
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost">
<Sun
class="absolute w-5 h-5 transition-all scale-100 dark:scale-0"
/>
<Moon
class="w-5 h-5 transition-all scale-0 dark:scale-100"
/>
<span class="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
class="min-w-min"
>
<DropdownMenuItem
class="cursor-pointer"
@click="colorMode.preference = 'light'"
>
<Sun class="w-4 h-4 mr-1" />
Light
</DropdownMenuItem>
<DropdownMenuItem
class="cursor-pointer"
@click="colorMode.preference = 'dark'"
>
<Moon class="w-4 h-4 mr-1" />
Dark
</DropdownMenuItem>
<DropdownMenuItem
class="cursor-pointer"
@click="colorMode.preference = 'system'"
>
<Laptop class="w-4 h-4 mr-1" />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@ -10,14 +10,14 @@ const route = useRoute()
@update:model-value="navigateTo"
>
<TabsList>
<TabsTrigger value="/dashboard">
Analysis
</TabsTrigger>
<TabsTrigger
value="/dashboard/links"
>
Links
</TabsTrigger>
<TabsTrigger value="/dashboard/analysis">
Analysis
</TabsTrigger>
</TabsList>
</Tabs>
<slot name="left" />

View File

@ -114,13 +114,8 @@ async function onSubmit(formData) {
body: link,
})
dialogOpen.value = false
emit('update:link', newLink)
if (isEdit) {
toast('Link updated successfully')
}
else {
toast('Link created successfully')
}
emit('update:link', newLink, isEdit ? 'edit' : 'create')
isEdit ? toast('Link updated successfully') : toast('Link created successfully')
}
const { previewMode } = useRuntimeConfig().public

View File

@ -14,7 +14,7 @@ async function getLinks() {
cursor,
},
})
links.value = links.value.concat(data.links).filter(Boolean) // Sometimes cloudflare will return null, filter out
links.value = links.value.concat(data.links)
cursor = data.cursor
listComplete = data.list_complete
}
@ -22,7 +22,7 @@ async function getLinks() {
const { isLoading } = useInfiniteScroll(
document,
getLinks,
{ distance: 150, interval: 1000, canLoadMore: () => !listComplete },
{ distance: 10, interval: 1000, canLoadMore: () => !listComplete },
)
function updateLinkList(link, type) {
@ -59,11 +59,5 @@ function updateLinkList(link, type) {
>
<Loader class="animate-spin" />
</div>
<div
v-if="!isLoading && listComplete"
class="flex items-center justify-center text-sm"
>
No more
</div>
</main>
</template>

View File

@ -1,6 +1,6 @@
<template>
<main class="grid gap-8 lg:grid-cols-12">
<LazyDashboardMetricsLocations class="col-span-1 lg:col-span-8" />
<DashboardMetricsLocations class="col-span-1 lg:col-span-8" />
<DashboardMetricsGroup
class="lg:col-span-4"
:tabs="['country', 'region', 'city']"

View File

@ -1,19 +1,26 @@
<script setup>
import { VisSingleContainer, VisTopoJSONMap, VisTopoJSONMapSelectors } from '@unovis/vue'
import { WorldMapSimplestTopoJSON } from '@unovis/ts/maps'
import WorldMapTopoJSON from '@/assets/location/world-topo.json' // https://github.com/apache/echarts/blob/master/test/data/map/json/world.json
import { ChartTooltip } from '@/components/ui/chart'
WorldMapTopoJSON.objects.states.geometries.map((state) => {
const name = state.properties.name
const country = WorldMapSimplestTopoJSON.objects.countries.geometries.find(country => country.properties.name === name)
state.id = state.name || ''
if (country) {
state.id = country.id || ''
state.properties = country.properties
}
return state
})
const id = inject('id')
const startAt = inject('startAt')
const endAt = inject('endAt')
const worldMapTopoJSON = ref({})
const areaData = ref([])
async function getWorldMapJSON() {
const data = await $fetch('/world.json')
worldMapTopoJSON.value = data
}
async function getMapData() {
areaData.value = []
const { data } = await useAPI('/api/stats/metrics', {
@ -35,7 +42,6 @@ async function getMapData() {
const stopWatchTime = watch([startAt, endAt], getMapData)
onMounted(() => {
getWorldMapJSON()
getMapData()
})
@ -66,13 +72,12 @@ const Tooltip = {
<CardContent class="flex-1 flex [&_[data-radix-aspect-ratio-wrapper]]:flex-1">
<AspectRatio :ratio="65 / 30">
<VisSingleContainer
v-if="worldMapTopoJSON.type"
:data="{ areas: areaData }"
class="h-full"
>
<VisTopoJSONMap
:topojson="worldMapTopoJSON"
map-feature-name="countries"
:topojson="WorldMapTopoJSON"
map-feature-name="states"
/>
<ChartSingleTooltip
index="id"

View File

@ -1,29 +1,26 @@
<script setup>
// https://vue3-simple-icons.wyatt-herkamp.dev/
import {
AndroidIcon,
AppleIcon,
AndroidIcon,
DebianIcon,
FacebookIcon,
FirefoxBrowserIcon,
GnuIcon,
GoogleChromeIcon,
GoogleIcon,
HuaweiIcon,
IOsIcon,
// InternetExplorerIcon,
InternetExplorerIcon,
LinuxIcon,
MacOsIcon,
// MicrosoftEdgeIcon,
MicrosoftEdgeIcon,
OperaIcon,
SafariIcon,
SamsungIcon,
UbuntuIcon,
VivoIcon,
WeChatIcon,
WearOsIcon,
// WindowsIcon,
XIcon,
WindowsIcon,
XiaomiIcon,
YandexCloudIcon,
} from 'vue3-simple-icons'
@ -50,7 +47,6 @@ defineProps({
const iconMaps = {
'android': AndroidIcon,
'android browser': AndroidIcon,
'browser': Globe,
'chrome': GoogleChromeIcon,
'chrome headless': GoogleChromeIcon,
@ -59,15 +55,15 @@ const iconMaps = {
'curl': Terminal,
'debian': DebianIcon,
'desktop': MonitorCheck,
'edge': MicrosoftEdgeIcon,
'facebook': FacebookIcon,
'facebookexternalhit': FacebookIcon,
'firefox': FirefoxBrowserIcon,
'googlebot': GoogleIcon,
'googlebot-image': GoogleIcon,
'gnu': GnuIcon,
'harmonyos': HuaweiIcon,
'huawei browser': HuaweiIcon,
// 'ie': InternetExplorerIcon,
'ie': InternetExplorerIcon,
'ios': IOsIcon,
'ipad': AppleIcon,
'iphone': AppleIcon,
@ -86,11 +82,10 @@ const iconMaps = {
'safari': SafariIcon,
'samsung internet': SamsungIcon,
'tablet': Tablet,
'twitterbot': XIcon,
'ubuntu': UbuntuIcon,
'vivo browser': VivoIcon,
'wechat': WeChatIcon,
'wearable': WearOsIcon,
'windows': WindowsIcon,
'yandex': YandexCloudIcon,
}
</script>

View File

@ -3,7 +3,7 @@
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
Deployment immediately.
</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.
@ -12,7 +12,6 @@
<HomeLink
href="https://github.com/ccbikai/sink?tab=readme-ov-file#%EF%B8%8F-deployment"
type="inverted"
title="Start Deploy"
>
Start Deploy
</HomeLink>

View File

@ -1,5 +1,5 @@
<script setup>
import { AreaChart, Hourglass, Link, Paintbrush, ServerOff, Sparkles } from 'lucide-vue-next'
import { Link, AreaChart, ServerOff, Paintbrush, Sparkles, Hourglass } from 'lucide-vue-next'
const features = ref([
{

View File

@ -10,6 +10,12 @@ const { title, description } = useAppConfig()
<main
class="grid pt-8 pb-8 lg:grid-cols-2 place-items-center md:py-12"
>
<div class="hidden py-6 md:order-1 md:block">
<div
class="w-[512px]"
v-html="heroImg"
/>
</div>
<div>
<h1
class="text-5xl font-bold lg:text-6xl xl:text-7xl lg:tracking-tight xl:tracking-tighter"
@ -23,7 +29,6 @@ const { title, description } = useAppConfig()
<HomeLink
href="/dashboard"
target="_blank"
title="Dashboard"
class="flex items-center justify-center gap-1"
rel="noopener"
>
@ -37,7 +42,6 @@ const { title, description } = useAppConfig()
type="outline"
rel="noopener"
href="https://github.com/ccbikai/sink"
title="Github"
class="flex items-center justify-center gap-1"
target="_blank"
>
@ -48,11 +52,5 @@ const { title, description } = useAppConfig()
</HomeLink>
</div>
</div>
<div class="hidden py-6 md:block">
<div
class="w-[512px]"
v-html="heroImg"
/>
</div>
</main>
</template>

View File

@ -5,9 +5,8 @@ import { ArrowRight } from 'lucide-vue-next'
<template>
<a
href="https://x.com/0xKaiBi"
href="https://x.com/ccbikai"
target="_blank"
title="X(Twitter)"
class="inline-flex items-center px-3 py-1 mx-auto my-4 space-x-1 text-sm font-medium rounded-lg bg-muted"
>
<XIcon class="w-4 h-4" />

View File

@ -1,5 +1,5 @@
<script setup>
import { BloggerIcon, GitHubIcon, GmailIcon, MastodonIcon, TelegramIcon, XIcon } from 'vue3-simple-icons'
import { GmailIcon, TelegramIcon, BloggerIcon, XIcon, MastodonIcon, GitHubIcon } from 'vue3-simple-icons'
const email = ref(null)
onMounted(() => {
@ -8,18 +8,16 @@ onMounted(() => {
</script>
<template>
<section class="md:pt-6">
<section class="text-gray-700 bg-white md:pt-6">
<div class="container flex flex-col items-center py-8 mx-auto sm:flex-row">
<a
href="/"
class="text-xl font-black leading-none text-gray-900 select-none dark:text-gray-100 logo"
title="Sink"
class="text-xl font-black leading-none text-gray-900 select-none logo"
>Sink</a>
<a
class="mt-4 text-sm text-gray-500 sm:ml-4 sm:pl-4 sm:border-l sm:border-gray-200 sm:mt-0"
href="https://html.zone"
target="_blank"
title="HTML.ZONE"
>
&copy; {{ new Date().getFullYear() }} Products of HTML.ZONE
</a>
@ -61,7 +59,7 @@ onMounted(() => {
</a>
<a
href="https://x.com/0xKaiBi"
href="https://x.com/ccbikai"
target="_blank"
title="Twitter"
class="text-gray-400 hover:text-gray-500"

View File

@ -1,13 +1,12 @@
<script setup>
import { Ellipsis, X } from 'lucide-vue-next'
import { GitHubIcon } from 'vue3-simple-icons'
import SwitchTheme from '../SwitchTheme.vue'
const showMenu = ref(false)
</script>
<template>
<section class="pb-6">
<section class="pb-6 bg-white">
<nav class="container relative z-50 h-24 select-none">
<div
class="container relative flex flex-wrap items-center justify-between h-24 px-0 mx-auto overflow-hidden font-medium border-b border-gray-200 md:overflow-visible lg:justify-center"
@ -15,11 +14,10 @@ const showMenu = ref(false)
<div class="flex items-center justify-start w-1/4 h-full pr-4">
<a
href="/"
title="Sink"
class="flex items-center py-4 space-x-2 text-xl font-black text-gray-900 dark:text-gray-100 md:py-0"
class="flex items-center py-4 space-x-2 text-xl font-extrabold text-gray-900 md:py-0"
>
<span
class="flex items-center justify-center w-8 h-8 rounded-full"
class="flex items-center justify-center w-8 h-8 text-white bg-gray-900 rounded-full"
>
<img
src="/sink.png"
@ -37,12 +35,12 @@ const showMenu = ref(false)
@touchmove.prevent
>
<div
class="flex-col w-full h-auto overflow-hidden rounded-lg bg-background md:overflow-visible md:rounded-none md:relative md:flex md:flex-row"
class="flex-col w-full h-auto overflow-hidden bg-white rounded-lg md:bg-transparent md:overflow-visible md:rounded-none md:relative md:flex md:flex-row"
>
<a
href="/"
title="Sink"
class="inline-flex items-center w-auto h-16 px-4 text-xl font-black leading-none text-gray-900 dark:text-gray-100 md:hidden"
target="_blank"
class="inline-flex items-center w-auto h-16 px-6 text-xl font-black leading-none text-gray-900 md:hidden"
>
<span
class="flex items-center justify-center w-8 h-8 text-white bg-gray-900 rounded-full"
@ -60,30 +58,24 @@ const showMenu = ref(false)
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"
>
<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"
class="w-full px-6 py-2 mr-0 text-gray-700 cursor-pointer md:px-3 md:mr-2 lg:mr-3 md:w-auto"
href="/dashboard"
title="Sink Dashboard"
>Dashboard</a>
<a
href="https://github.com/ccbikai/sink"
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: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"
>
<GitHubIcon
class="w-5 h-5 mr-1"
/>
GitHub</a>
<span class="ml-1">
<SwitchTheme />
</span>
</div>
</div>
</div>
<div
class="absolute right-0 flex flex-col items-center justify-center w-10 h-10 rounded-full cursor-pointer md:hidden hover:bg-muted"
:class="{ 'right-2': showMenu }"
class="absolute right-0 flex flex-col items-center justify-center w-10 h-10 bg-white rounded-full cursor-pointer md:hidden hover:bg-gray-100"
@click="showMenu = !showMenu"
>
<Ellipsis

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
import { DropdownMenuRoot, type DropdownMenuRootEmits, type DropdownMenuRootProps, useForwardPropsEmits } from 'radix-vue'
const props = defineProps<DropdownMenuRootProps>()
const emits = defineEmits<DropdownMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRoot v-bind="forwarded">
<slot />
</DropdownMenuRoot>
</template>

View File

@ -1,40 +0,0 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import {
DropdownMenuCheckboxItem,
type DropdownMenuCheckboxItemEmits,
type DropdownMenuCheckboxItemProps,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from 'radix-vue'
import { Check } from 'lucide-vue-next'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuCheckboxItem
v-bind="forwarded"
:class=" cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class,
)"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<Check class="w-4 h-4" />
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>

View File

@ -1,38 +0,0 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import {
DropdownMenuContent,
type DropdownMenuContentEmits,
type DropdownMenuContentProps,
DropdownMenuPortal,
useForwardPropsEmits,
} from 'radix-vue'
import { cn } from '@/utils'
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(),
{
sideOffset: 4,
},
)
const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
v-bind="forwarded"
:class="cn('z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>

View File

@ -1,11 +0,0 @@
<script setup lang="ts">
import { DropdownMenuGroup, type DropdownMenuGroupProps } from 'radix-vue'
const props = defineProps<DropdownMenuGroupProps>()
</script>
<template>
<DropdownMenuGroup v-bind="props">
<slot />
</DropdownMenuGroup>
</template>

View File

@ -1,28 +0,0 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import { DropdownMenuItem, type DropdownMenuItemProps, useForwardProps } from 'radix-vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuItemProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuItem
v-bind="forwardedProps"
:class="cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
props.class,
)"
>
<slot />
</DropdownMenuItem>
</template>

View File

@ -1,24 +0,0 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import { DropdownMenuLabel, type DropdownMenuLabelProps, useForwardProps } from 'radix-vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuLabel
v-bind="forwardedProps"
:class="cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)"
>
<slot />
</DropdownMenuLabel>
</template>

View File

@ -1,19 +0,0 @@
<script setup lang="ts">
import {
DropdownMenuRadioGroup,
type DropdownMenuRadioGroupEmits,
type DropdownMenuRadioGroupProps,
useForwardPropsEmits,
} from 'radix-vue'
const props = defineProps<DropdownMenuRadioGroupProps>()
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRadioGroup v-bind="forwarded">
<slot />
</DropdownMenuRadioGroup>
</template>

View File

@ -1,41 +0,0 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
type DropdownMenuRadioItemEmits,
type DropdownMenuRadioItemProps,
useForwardPropsEmits,
} from 'radix-vue'
import { Circle } from 'lucide-vue-next'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuRadioItemEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuRadioItem
v-bind="forwarded"
:class="cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class,
)"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<Circle class="h-2 w-2 fill-current" />
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>

View File

@ -1,22 +0,0 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import {
DropdownMenuSeparator,
type DropdownMenuSeparatorProps,
} from 'radix-vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuSeparatorProps & {
class?: HTMLAttributes['class']
}>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<DropdownMenuSeparator v-bind="delegatedProps" :class="cn('-mx-1 my-1 h-px bg-muted', props.class)" />
</template>

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span :class="cn('ml-auto text-xs tracking-widest opacity-60', props.class)">
<slot />
</span>
</template>

View File

@ -1,19 +0,0 @@
<script setup lang="ts">
import {
DropdownMenuSub,
type DropdownMenuSubEmits,
type DropdownMenuSubProps,
useForwardPropsEmits,
} from 'radix-vue'
const props = defineProps<DropdownMenuSubProps>()
const emits = defineEmits<DropdownMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuSub v-bind="forwarded">
<slot />
</DropdownMenuSub>
</template>

View File

@ -1,30 +0,0 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import {
DropdownMenuSubContent,
type DropdownMenuSubContentEmits,
type DropdownMenuSubContentProps,
useForwardPropsEmits,
} from 'radix-vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuSubContent
v-bind="forwarded"
:class="cn('z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', props.class)"
>
<slot />
</DropdownMenuSubContent>
</template>

View File

@ -1,33 +0,0 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import {
DropdownMenuSubTrigger,
type DropdownMenuSubTriggerProps,
useForwardProps,
} from 'radix-vue'
import { ChevronRight } from 'lucide-vue-next'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuSubTrigger
v-bind="forwardedProps"
:class="cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
props.class,
)"
>
<slot />
<ChevronRight class="ml-auto h-4 w-4" />
</DropdownMenuSubTrigger>
</template>

View File

@ -1,13 +0,0 @@
<script setup lang="ts">
import { DropdownMenuTrigger, type DropdownMenuTriggerProps, useForwardProps } from 'radix-vue'
const props = defineProps<DropdownMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuTrigger class="outline-none" v-bind="forwardedProps">
<slot />
</DropdownMenuTrigger>
</template>

View File

@ -1,16 +0,0 @@
export { DropdownMenuPortal } from 'radix-vue'
export { default as DropdownMenu } from './DropdownMenu.vue'
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue'
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
export { default as DropdownMenuSub } from './DropdownMenuSub.vue'
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue'
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue'

View File

@ -1,53 +0,0 @@
# Sink API
Writing API documentation manually can be very laborious, and we will automatically generate documents after the official release of [Nitro's OpenAPI](https://nitro.unjs.io/config#openapi).
This place provides an example of creating a short link API. Other APIs are currently available for viewing through browser developer tools.
## API Reference
### Create Short Link
```http
POST /api/link/create
```
| Header | Description |
| :----- | :------------------------- |
| `authorization` | `Bearer SinkCool` |
| `content-type` | `application/json` |
#### Example
```http
POST /api/link/create
HEADER authorization: Bearer SinkCool
HEADER content-type: application/json
BODY {
"url": "https://github.com/ccbikai/Sink/issues/14",
"slug": "issue14"
}
```
The BODY data must be JSON.
```http
RESPONSE 201
BODY {
"link": {
"id": "xpqhaurv5q",
"url": "https://github.com/ccbikai/Sink/issues/14",
"slug": "issue14",
"createdAt": 1718119809,
"updatedAt": 1718119809
}
}
```
| Parameter | Type | Description |
| :-------- | :------- | :------------------------- |
| `id` | `string` | This is automatically generated by Sink |
| `url` | `string` | This is confirmation of the submitted URL and is required. |
| `slug` | `string` | This is slug generated by the system, either automatically or from the input (if provided) |
| `createdAt` | `timestamp` | This is automatically generated with a UNIX Timestamp. |
| `updatedAt` | `timestamp` | This is automatically generated with a UNIX Timestamp. |

View File

@ -14,14 +14,6 @@ Sets the default length of the generated SLUG.
Redirects default to use HTTP 301 status code, you can set it to `302`/`307`/`308`.
## `NUXT_LINK_CACHE_TTL`
Cache links can speed up access, but setting them too long may result in slow changes taking effect. The default value is 60 seconds.
## `NUXT_REDIRECT_WITH_QUERY`
URL parameters are not carried during link redirection by default and it is not recommended to enable this feature.
## `NUXT_HOME_URL`
The default Sink homepage is the introduction page, you can replace it with your own website.

View File

@ -1,31 +0,0 @@
# FAQs
## 1. Why can't I create a link?
Please check the Cloudflare KV bindings, the KV environment variable name should be all uppercase letters.
<details>
<summary><b>Screenshots</b></summary>
<img alt="KV" src="https://github.com/ccbikai/Sink/assets/21292149/3b7d584c-3afe-4d24-8c9e-e0d549c47438"/>
</details>
## 2. Why can't I log in?
Please check if `NUXT_SITE_TOKEN` is set to pure numbers, Sink does not support pure number Tokens, we consider this to be unsafe.
## 3. Why can't I see the analytics data?
Analytics data needs to read Cloudflare's data, check if `NUXT_CF_ACCOUNT_ID` and `NUXT_CF_API_TOKEN` are correctly configured. Pay attention to the account id deployment zone id, whether the Worker analytics engine is turned on.
<details>
<summary><b>Screenshots</b></summary>
<img alt="Analytics" src="https://github.com/ccbikai/Sink/assets/21292149/aceede26-5aa1-400c-8d06-d6adc46bdcb8"/>
</details>
## 4. I don't want the current homepage? Can it be redirected to my blog?
Of course. Please set the environment variable `NUXT_HOME_URL` to your blog or official website address.
## 5. Why can't I see the statistics after deploying with NuxtHub?
NuxtHub's ANALYTICS points to its dataset, you need to set the `NUXT_DATASET` environment variable to point to the same dataset.

View File

@ -1,18 +1,17 @@
// @ts-check
import antfu from '@antfu/eslint-config'
// import antfu from '@antfu/eslint-config'
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
antfu(),
// antfu(),
{
ignores: ['components/ui', '.data', 'public/world.json'],
ignores: ['components/ui'],
},
{
rules: {
'@typescript-eslint/ban-ts-comment': 'off',
'no-console': 'off',
'node/prefer-global/process': 'off',
'vue/no-v-html': 'off',
},
},
)

View File

@ -1,19 +1,12 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: true },
modules: [
'@nuxthub/core',
'shadcn-nuxt',
'@nuxt/eslint',
'@nuxtjs/tailwindcss',
'@nuxtjs/color-mode',
],
colorMode: {
classSuffix: '',
},
routeRules: {
'/': {
prerender: true,
@ -21,50 +14,38 @@ export default defineNuxtConfig({
'/dashboard/**': {
ssr: false,
},
'/dashboard': {
redirect: '/dashboard/links',
},
},
hub: {
ai: true,
analytics: true,
blob: false,
cache: false,
database: false,
kv: true,
// ai: true,
},
eslint: {
config: {
stylistic: true,
standalone: false,
},
},
nitro: {
experimental: {
// Enable Server API documentation within NuxtHub
openAPI: true,
},
},
runtimeConfig: {
siteToken: 'SinkCool',
redirectStatusCode: '301',
linkCacheTtl: 60,
redirectWithQuery: false,
homeURL: '',
cfAccountId: '',
cfApiToken: '',
dataset: 'sink',
aiModel: '@cf/meta/llama-3.1-8b-instruct',
aiModel: '@cf/meta/llama-3-8b-instruct',
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"}`,
public: {
previewMode: '',
slugDefaultLength: '6',
},
},
compatibilityDate: '2024-07-08',
})

View File

@ -1,19 +1,15 @@
{
"name": "sink",
"type": "module",
"version": "0.1.4",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@9.7.1",
"engines": {
"node": ">=20.11"
},
"packageManager": "pnpm@9.1.2",
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"build:map": "node scripts/build-map.js",
"preview": "wrangler pages dev dist",
"deploy": "wrangler pages deploy dist",
"postinstall": "npm run build:map && nuxt prepare",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "nuxt typecheck",
@ -21,42 +17,41 @@
"lint-staged": "lint-staged"
},
"dependencies": {
"@unovis/ts": "^1.4.4",
"@unovis/vue": "^1.4.4",
"@vee-validate/zod": "^4.13.2",
"@vueuse/core": "^11.0.0",
"@unovis/ts": "^1.4.1",
"@unovis/vue": "^1.4.1",
"@vee-validate/zod": "^4.12.8",
"@vueuse/core": "^10.9.0",
"intl-parse-accept-language": "^1.0.0",
"lucide-vue-next": "^0.428.0",
"lucide-vue-next": "^0.379.0",
"mysql-bricks": "^1.1.2",
"nanoid": "^5.0.7",
"pluralize": "^8.0.0",
"qr-code-styling": "1.6.0-rc.1",
"radix-vue": "^1.9.4",
"radix-vue": "^1.8.1",
"ua-parser-js": "next",
"vee-validate": "^4.13.2",
"virtua": "^0.33.7",
"vue-sonner": "^1.1.4",
"vue3-simple-icons": "^13.2.0",
"vee-validate": "^4.12.8",
"virtua": "^0.31.0",
"vue-sonner": "^1.1.2",
"vue3-simple-icons": "^11.13.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@antfu/eslint-config": "^2.26.0",
"@nuxt/eslint": "^0.5.0",
"@nuxt/eslint-config": "^0.5.0",
"@nuxthub/core": "^0.7.3",
"@nuxtjs/color-mode": "^3.4.4",
"@nuxtjs/tailwindcss": "^6.12.1",
"@antfu/eslint-config": "^2.18.1",
"@nuxt/eslint": "^0.3.13",
"@nuxt/eslint-config": "^0.3.13",
"@nuxthub/core": "^0.5.17",
"@nuxtjs/tailwindcss": "^6.12.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"eslint": "^9.9.0",
"lint-staged": "^15.2.9",
"nuxt": "^3.12.4",
"eslint": "^8.57.0",
"lint-staged": "^15.2.4",
"nuxt": "^3.11.2",
"shadcn-nuxt": "^0.10.4",
"simple-git-hooks": "^2.11.1",
"tailwind-merge": "^2.5.2",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"vue-tsc": "^2.0.29",
"wrangler": "^3.72.0"
"vue-tsc": "^2.0.19",
"wrangler": "^3.57.1"
},
"simple-git-hooks": {
"pre-commit": "npm run lint-staged"

View File

@ -17,11 +17,10 @@ async function getLink() {
}
function updateLink(link, type) {
if (type === 'delete') {
if (type === 'delete')
navigateTo('/dashboard/links', {
replace: true,
})
}
}
onMounted(() => {

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -5,7 +5,7 @@ const { slugRegex } = useAppConfig()
const slugDefaultLength = +useRuntimeConfig().public.slugDefaultLength
export const nanoid = (length: number = slugDefaultLength) => customAlphabet('23456789abcdefghjkmnpqrstuvwxyz', length)
export const nanoid = (length: number = slugDefaultLength) => customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', length)
export const LinkSchema = z.object({
id: z.string().trim().max(26).default(nanoid(10)),

View File

@ -1,5 +0,0 @@
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { WorldMapSimplestTopoJSON } from '@unovis/ts/maps.js'
writeFileSync(join(import.meta.dirname, '../public/world.json'), JSON.stringify(WorldMapSimplestTopoJSON), 'utf8')

View File

@ -31,8 +31,7 @@ export default eventHandler(async (event) => {
content: url,
},
]
// @ts-expect-error Workers AI is not typed
const { response } = await hubAI().run(aiModel, { messages })
const { response } = await AI.run(aiModel, { messages })
return destr(response)
}
else {

View File

@ -12,7 +12,6 @@ export default eventHandler(async (event) => {
statusText: 'Link already exists',
})
}
else {
const expiration = getExpiration(event, link.expiration)

View File

@ -1,11 +1,11 @@
import type { z } from 'zod'
import { parsePath, withQuery } from 'ufo'
import { parsePath } from 'ufo'
import type { LinkSchema } from '@/schemas/link'
export default eventHandler(async (event) => {
const { pathname: slug } = parsePath(event.path.slice(1)) // remove leading slash
const { slugRegex, reserveSlug } = useAppConfig(event)
const { homeURL, linkCacheTtl, redirectWithQuery } = useRuntimeConfig(event)
const { homeURL } = useRuntimeConfig(event)
const { cloudflare } = event.context
if (event.path === '/' && homeURL)
@ -13,7 +13,7 @@ export default eventHandler(async (event) => {
if (slug && !reserveSlug.includes(slug) && slugRegex.test(slug) && cloudflare) {
const { KV } = cloudflare.env
const link: z.infer<typeof LinkSchema> | null = await KV.get(`link:${slug}`, { type: 'json', cacheTtl: linkCacheTtl })
const link: z.infer<typeof LinkSchema> | null = await KV.get(`link:${slug}`, { type: 'json' })
if (link) {
event.context.link = link
try {
@ -22,8 +22,7 @@ export default eventHandler(async (event) => {
catch (error) {
console.error('Failed write access log:', error)
}
const target = redirectWithQuery ? withQuery(link.url, getQuery(event)) : link.url
return sendRedirect(event, target, +useRuntimeConfig(event).redirectStatusCode)
return sendRedirect(event, link.url, +useRuntimeConfig(event).redirectStatusCode)
}
}
})

View File

@ -2,12 +2,11 @@ import type { H3Event } from 'h3'
import { parseURL } from 'ufo'
import { UAParser } from 'ua-parser-js'
import {
Apps,
Bots,
CLIs,
Crawlers,
Emails,
ExtraDevices,
Fetchers,
InApps,
MediaPlayers,
Modules,
} from 'ua-parser-js/extensions'
@ -69,7 +68,7 @@ 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: [Apps.browser || [], Bots.browser || [], CLIs.browser || [], Emails.browser || [], MediaPlayers.browser || [], Modules.browser || []].flat(),
device: [ExtraDevices.device || []].flat(),
})).getResult()
@ -91,6 +90,7 @@ export function useAccessLog(event: H3Event) {
language,
os: uaInfo?.os?.name,
browser: uaInfo?.browser?.name,
// @ts-expect-error todo
browserType: uaInfo?.browser?.type,
device: uaInfo?.device?.model,
deviceType: uaInfo?.device?.type,

View File

@ -9,7 +9,7 @@ module.exports = {
theme: {
container: {
center: true,
padding: '1rem',
padding: '2rem',
screens: {
'2xl': '1400px',
},

View File

@ -7,10 +7,6 @@ export function useAPI(api: string, options?: object): Promise<unknown> {
Authorization: `Bearer ${localStorage.getItem('SinkSiteToken') || ''}`,
},
})).catch((error) => {
if (error?.status === 401) {
localStorage.removeItem('SinkSiteToken')
navigateTo('/dashboard/login')
}
if (error?.data?.statusMessage) {
toast(error?.data?.statusMessage)
}