├── .cursor └── rules │ ├── api-patterns.mdc │ ├── coding-standards.mdc │ ├── component-patterns.mdc │ ├── deployment-and-infrastructure.mdc │ ├── development-guidelines.mdc │ └── project-overview.mdc ├── .editorconfig ├── .env.example ├── .github └── FUNDING.yml ├── .gitignore ├── .node-version ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── app ├── app.config.ts ├── app.vue ├── assets │ ├── css │ │ └── tailwind.css │ └── images │ │ ├── 404.svg │ │ ├── cloudflare.png │ │ ├── hero.svg │ │ └── nuxtjs.png ├── components │ ├── SwitchLanguage.vue │ ├── SwitchTheme.vue │ ├── dashboard │ │ ├── Breadcrumb.vue │ │ ├── DatePicker.vue │ │ ├── Filters.vue │ │ ├── Logout.vue │ │ ├── Nav.vue │ │ ├── TimePicker.vue │ │ ├── analysis │ │ │ ├── Counters.vue │ │ │ ├── Index.vue │ │ │ ├── Views.vue │ │ │ └── metrics │ │ │ │ ├── Group.vue │ │ │ │ ├── Index.vue │ │ │ │ ├── List.vue │ │ │ │ ├── Locations.vue │ │ │ │ ├── Metric.vue │ │ │ │ └── name │ │ │ │ ├── Icon.vue │ │ │ │ ├── Index.vue │ │ │ │ ├── Referer.vue │ │ │ │ └── Slug.vue │ │ ├── links │ │ │ ├── Delete.vue │ │ │ ├── Editor.vue │ │ │ ├── Index.vue │ │ │ ├── Link.vue │ │ │ ├── QRCode.vue │ │ │ ├── Search.vue │ │ │ └── Sort.vue │ │ └── realtime │ │ │ ├── Chart.vue │ │ │ ├── Globe.vue │ │ │ ├── Index.vue │ │ │ └── Logs.vue │ ├── home │ │ ├── Cta.vue │ │ ├── Features.vue │ │ ├── Hero.vue │ │ ├── Link.vue │ │ ├── Logos.vue │ │ └── Twitter.vue │ ├── layouts │ │ ├── Footer.vue │ │ └── Header.vue │ ├── login │ │ └── index.vue │ ├── spark-ui │ │ ├── AnimatedList.vue │ │ └── Notification.vue │ └── ui │ │ ├── accordion │ │ ├── Accordion.vue │ │ ├── AccordionContent.vue │ │ ├── AccordionItem.vue │ │ ├── AccordionTrigger.vue │ │ └── index.ts │ │ ├── alert-dialog │ │ ├── AlertDialog.vue │ │ ├── AlertDialogAction.vue │ │ ├── AlertDialogCancel.vue │ │ ├── AlertDialogContent.vue │ │ ├── AlertDialogDescription.vue │ │ ├── AlertDialogFooter.vue │ │ ├── AlertDialogHeader.vue │ │ ├── AlertDialogTitle.vue │ │ ├── AlertDialogTrigger.vue │ │ └── index.ts │ │ ├── alert │ │ ├── Alert.vue │ │ ├── AlertDescription.vue │ │ ├── AlertTitle.vue │ │ └── index.ts │ │ ├── aspect-ratio │ │ ├── AspectRatio.vue │ │ └── index.ts │ │ ├── auto-form │ │ ├── AutoForm.vue │ │ ├── AutoFormField.vue │ │ ├── AutoFormFieldArray.vue │ │ ├── AutoFormFieldBoolean.vue │ │ ├── AutoFormFieldDate.vue │ │ ├── AutoFormFieldEnum.vue │ │ ├── AutoFormFieldFile.vue │ │ ├── AutoFormFieldInput.vue │ │ ├── AutoFormFieldNumber.vue │ │ ├── AutoFormFieldObject.vue │ │ ├── AutoFormLabel.vue │ │ ├── constant.ts │ │ ├── dependencies.ts │ │ ├── index.ts │ │ ├── interface.ts │ │ └── utils.ts │ │ ├── avatar │ │ ├── Avatar.vue │ │ ├── AvatarFallback.vue │ │ ├── AvatarImage.vue │ │ └── index.ts │ │ ├── badge │ │ ├── Badge.vue │ │ └── index.ts │ │ ├── breadcrumb │ │ ├── Breadcrumb.vue │ │ ├── BreadcrumbEllipsis.vue │ │ ├── BreadcrumbItem.vue │ │ ├── BreadcrumbLink.vue │ │ ├── BreadcrumbList.vue │ │ ├── BreadcrumbPage.vue │ │ ├── BreadcrumbSeparator.vue │ │ └── index.ts │ │ ├── button │ │ ├── Button.vue │ │ └── index.ts │ │ ├── calendar │ │ ├── Calendar.vue │ │ ├── CalendarCell.vue │ │ ├── CalendarCellTrigger.vue │ │ ├── CalendarGrid.vue │ │ ├── CalendarGridBody.vue │ │ ├── CalendarGridHead.vue │ │ ├── CalendarGridRow.vue │ │ ├── CalendarHeadCell.vue │ │ ├── CalendarHeader.vue │ │ ├── CalendarHeading.vue │ │ ├── CalendarNextButton.vue │ │ ├── CalendarPrevButton.vue │ │ └── index.ts │ │ ├── card │ │ ├── Card.vue │ │ ├── CardContent.vue │ │ ├── CardDescription.vue │ │ ├── CardFooter.vue │ │ ├── CardHeader.vue │ │ ├── CardTitle.vue │ │ └── index.ts │ │ ├── chart-area │ │ ├── AreaChart.vue │ │ └── index.ts │ │ ├── chart-bar │ │ ├── BarChart.vue │ │ └── index.ts │ │ ├── chart │ │ ├── ChartCrosshair.vue │ │ ├── ChartLegend.vue │ │ ├── ChartSingleTooltip.vue │ │ ├── ChartTooltip.vue │ │ ├── index.ts │ │ └── interface.ts │ │ ├── checkbox │ │ ├── Checkbox.vue │ │ └── index.ts │ │ ├── command │ │ ├── Command.vue │ │ ├── CommandDialog.vue │ │ ├── CommandEmpty.vue │ │ ├── CommandGroup.vue │ │ ├── CommandInput.vue │ │ ├── CommandItem.vue │ │ ├── CommandList.vue │ │ ├── CommandSeparator.vue │ │ ├── CommandShortcut.vue │ │ └── index.ts │ │ ├── dialog │ │ ├── Dialog.vue │ │ ├── DialogClose.vue │ │ ├── DialogContent.vue │ │ ├── DialogDescription.vue │ │ ├── DialogFooter.vue │ │ ├── DialogHeader.vue │ │ ├── DialogScrollContent.vue │ │ ├── DialogTitle.vue │ │ ├── DialogTrigger.vue │ │ └── index.ts │ │ ├── drawer │ │ ├── Drawer.vue │ │ ├── DrawerContent.vue │ │ ├── DrawerDescription.vue │ │ ├── DrawerFooter.vue │ │ ├── DrawerHeader.vue │ │ ├── DrawerOverlay.vue │ │ ├── DrawerTitle.vue │ │ └── index.ts │ │ ├── dropdown-menu │ │ ├── DropdownMenu.vue │ │ ├── DropdownMenuCheckboxItem.vue │ │ ├── DropdownMenuContent.vue │ │ ├── DropdownMenuGroup.vue │ │ ├── DropdownMenuItem.vue │ │ ├── DropdownMenuLabel.vue │ │ ├── DropdownMenuRadioGroup.vue │ │ ├── DropdownMenuRadioItem.vue │ │ ├── DropdownMenuSeparator.vue │ │ ├── DropdownMenuShortcut.vue │ │ ├── DropdownMenuSub.vue │ │ ├── DropdownMenuSubContent.vue │ │ ├── DropdownMenuSubTrigger.vue │ │ ├── DropdownMenuTrigger.vue │ │ └── index.ts │ │ ├── form │ │ ├── FormControl.vue │ │ ├── FormDescription.vue │ │ ├── FormItem.vue │ │ ├── FormLabel.vue │ │ ├── FormMessage.vue │ │ ├── index.ts │ │ └── useFormField.ts │ │ ├── hover-card │ │ ├── HoverCard.vue │ │ ├── HoverCardContent.vue │ │ ├── HoverCardTrigger.vue │ │ └── index.ts │ │ ├── input │ │ ├── Input.vue │ │ └── index.ts │ │ ├── label │ │ ├── Label.vue │ │ └── index.ts │ │ ├── menubar │ │ ├── Menubar.vue │ │ ├── MenubarCheckboxItem.vue │ │ ├── MenubarContent.vue │ │ ├── MenubarGroup.vue │ │ ├── MenubarItem.vue │ │ ├── MenubarLabel.vue │ │ ├── MenubarMenu.vue │ │ ├── MenubarRadioGroup.vue │ │ ├── MenubarRadioItem.vue │ │ ├── MenubarSeparator.vue │ │ ├── MenubarShortcut.vue │ │ ├── MenubarSub.vue │ │ ├── MenubarSubContent.vue │ │ ├── MenubarSubTrigger.vue │ │ ├── MenubarTrigger.vue │ │ └── index.ts │ │ ├── navigation-menu │ │ ├── NavigationMenu.vue │ │ ├── NavigationMenuContent.vue │ │ ├── NavigationMenuIndicator.vue │ │ ├── NavigationMenuItem.vue │ │ ├── NavigationMenuLink.vue │ │ ├── NavigationMenuList.vue │ │ ├── NavigationMenuTrigger.vue │ │ ├── NavigationMenuViewport.vue │ │ └── index.ts │ │ ├── popover │ │ ├── Popover.vue │ │ ├── PopoverContent.vue │ │ ├── PopoverTrigger.vue │ │ └── index.ts │ │ ├── progress │ │ ├── Progress.vue │ │ └── index.ts │ │ ├── radio-group │ │ ├── RadioGroup.vue │ │ ├── RadioGroupItem.vue │ │ └── index.ts │ │ ├── range-calendar │ │ ├── RangeCalendar.vue │ │ ├── RangeCalendarCell.vue │ │ ├── RangeCalendarCellTrigger.vue │ │ ├── RangeCalendarGrid.vue │ │ ├── RangeCalendarGridBody.vue │ │ ├── RangeCalendarGridHead.vue │ │ ├── RangeCalendarGridRow.vue │ │ ├── RangeCalendarHeadCell.vue │ │ ├── RangeCalendarHeader.vue │ │ ├── RangeCalendarHeading.vue │ │ ├── RangeCalendarNextButton.vue │ │ ├── RangeCalendarPrevButton.vue │ │ └── index.ts │ │ ├── select │ │ ├── Select.vue │ │ ├── SelectContent.vue │ │ ├── SelectGroup.vue │ │ ├── SelectItem.vue │ │ ├── SelectItemText.vue │ │ ├── SelectLabel.vue │ │ ├── SelectScrollDownButton.vue │ │ ├── SelectScrollUpButton.vue │ │ ├── SelectSeparator.vue │ │ ├── SelectTrigger.vue │ │ ├── SelectValue.vue │ │ └── index.ts │ │ ├── separator │ │ ├── Separator.vue │ │ └── index.ts │ │ ├── skeleton │ │ ├── Skeleton.vue │ │ └── index.ts │ │ ├── sonner │ │ ├── Sonner.vue │ │ └── index.ts │ │ ├── switch │ │ ├── Switch.vue │ │ └── index.ts │ │ ├── table │ │ ├── Table.vue │ │ ├── TableBody.vue │ │ ├── TableCaption.vue │ │ ├── TableCell.vue │ │ ├── TableEmpty.vue │ │ ├── TableFooter.vue │ │ ├── TableHead.vue │ │ ├── TableHeader.vue │ │ ├── TableRow.vue │ │ └── index.ts │ │ ├── tabs │ │ ├── Tabs.vue │ │ ├── TabsContent.vue │ │ ├── TabsList.vue │ │ ├── TabsTrigger.vue │ │ └── index.ts │ │ ├── textarea │ │ ├── Textarea.vue │ │ └── index.ts │ │ └── tooltip │ │ ├── Tooltip.vue │ │ ├── TooltipContent.vue │ │ ├── TooltipProvider.vue │ │ ├── TooltipTrigger.vue │ │ └── index.ts ├── composables │ └── index.ts ├── error.vue ├── layouts │ └── default.vue ├── middleware │ └── auth.global.ts ├── pages │ ├── dashboard │ │ ├── analysis.vue │ │ ├── link.vue │ │ ├── links.vue │ │ ├── login.vue │ │ └── realtime.vue │ └── index.vue └── utils │ ├── api.ts │ ├── color.ts │ ├── events.ts │ ├── flag.ts │ ├── index.ts │ ├── number.ts │ └── time.ts ├── components.json ├── docs ├── api.md ├── configuration.md ├── deployment │ ├── pages.md │ └── workers.md ├── faqs.md └── images │ ├── faqs-Analytics_engine.png │ ├── faqs-kv.png │ ├── sink.cool_dashboard.png │ ├── sink.cool_dashboard_link_slug.png │ └── sink.cool_dashboard_links.png ├── eslint.config.mjs ├── i18n ├── i18n.config.ts ├── i18n.ts └── locales │ ├── de-DE.json │ ├── en-US.json │ ├── fr-FR.json │ ├── vi-VN.json │ ├── zh-CN.json │ └── zh-TW.json ├── nuxt.config.ts ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── banner.png ├── colos.json ├── countries.geojson ├── favicon.ico ├── icon-192-maskable.png ├── icon-192.png ├── icon.png ├── image.png ├── sink-1024.png ├── sink.png └── site.webmanifest ├── renovate.json ├── schemas ├── link.ts └── query.ts ├── scripts ├── build-colo.js └── build-map.js ├── server ├── api │ ├── link │ │ ├── ai.get.ts │ │ ├── create.post.ts │ │ ├── delete.post.ts │ │ ├── edit.put.ts │ │ ├── list.get.ts │ │ ├── query.get.ts │ │ ├── search.get.ts │ │ └── upsert.post.ts │ ├── location.ts │ ├── logs │ │ ├── events.ts │ │ └── locations.ts │ ├── stats │ │ ├── counters.get.ts │ │ ├── metrics.get.ts │ │ └── views.get.ts │ └── verify.ts ├── middleware │ ├── 1.redirect.ts │ └── 2.auth.ts ├── tsconfig.json └── utils │ ├── access-log.ts │ ├── cloudflare.ts │ ├── query-filter.ts │ ├── sql-bricks.ts │ └── time.ts ├── tailwind.config.js ├── tsconfig.json └── wrangler.jsonc /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = space 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NUXT_PUBLIC_PREVIEW_MODE=true 2 | NUXT_PUBLIC_SLUG_DEFAULT_LENGTH=5 3 | NUXT_SITE_TOKEN=SinkCool 4 | NUXT_REDIRECT_STATUS_CODE=308 5 | NUXT_LINK_CACHE_TTL=60 6 | NUXT_REDIRECT_WITH_QUERY=false 7 | NUXT_HOME_URL="https://sink.cool" 8 | NUXT_CF_ACCOUNT_ID=123456 9 | NUXT_CF_API_TOKEN=CloudflareAPIToken 10 | NUXT_DATASET=sink 11 | NUXT_AI_MODEL="@cf/meta/llama-3-8b-instruct" 12 | NUXT_AI_PROMPT="You are a URL shortening assistant......" 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ccbikai 2 | buy_me_a_coffee: miantiao 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | ./logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | .wrangler 26 | site 27 | cache 28 | public/world.json 29 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.15.1 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "vue.volar" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /app/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | title: 'Sink', 3 | email: 'sink.cool@miantiao.me', 4 | github: 'https://github.com/ccbikai/sink', 5 | twitter: 'https://sink.cool/kai', 6 | telegram: 'https://sink.cool/telegram', 7 | mastodon: 'https://sink.cool/mastodon', 8 | blog: 'https://sink.cool/blog', 9 | description: 'A Simple / Speedy / Secure Link Shortener with Analytics, 100% run on Cloudflare.', 10 | image: 'https://sink.cool/banner.png', 11 | previewTTL: 300, // 5 minutes 12 | slugRegex: /^[a-z0-9]+(?:-[a-z0-9]+)*$/i, 13 | reserveSlug: [ 14 | 'dashboard', 15 | ], 16 | }) 17 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /app/assets/images/cloudflare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/app/assets/images/cloudflare.png -------------------------------------------------------------------------------- /app/assets/images/nuxtjs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/app/assets/images/nuxtjs.png -------------------------------------------------------------------------------- /app/components/SwitchLanguage.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {{ $t('theme.toggle') }} 20 | 21 | 22 | 26 | 32 | 33 | {{ locale.emoji }} 34 | 35 | {{ locale.name }} 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/components/SwitchTheme.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 17 | {{ $t('theme.toggle') }} 18 | 19 | 20 | 24 | 28 | 29 | {{ $t('theme.light') }} 30 | 31 | 35 | 36 | {{ $t('theme.dark') }} 37 | 38 | 42 | 43 | {{ $t('theme.system') }} 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /app/components/dashboard/Breadcrumb.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {{ title }} 19 | 20 | 21 | 22 | 23 | 27 | {{ $t('dashboard.title') }} 28 | 29 | 30 | 31 | 32 | {{ title }} 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/components/dashboard/Logout.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | {{ $t('logout.title') }} 20 | 21 | {{ $t('logout.confirm') }} 22 | 23 | 24 | 25 | {{ $t('common.cancel') }} 26 | 27 | {{ $t('logout.action') }} 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/components/dashboard/Nav.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 12 | 13 | 16 | {{ $t('nav.links') }} 17 | 18 | 19 | {{ $t('nav.analysis') }} 20 | 21 | 22 | {{ $t('nav.realtime') }} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/components/dashboard/analysis/metrics/Group.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 19 | 20 | 25 | {{ tab }} 26 | 27 | 28 | 34 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /app/components/dashboard/analysis/metrics/name/Referer.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 14 | 17 | 22 | 23 | 28 | 29 | 30 | {{ name }} 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/components/dashboard/analysis/metrics/name/Slug.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | {{ name }} 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/dashboard/links/Delete.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {{ $t('links.delete_confirm_title') }} 33 | 34 | {{ $t('links.delete_confirm_desc') }} 35 | 36 | 37 | 38 | {{ $t('common.cancel') }} 39 | 40 | {{ $t('common.continue') }} 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/components/dashboard/realtime/Logs.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | 37 | 38 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /app/components/home/Cta.vue: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | {{ $t('home.cta.title') }} 7 | 8 | 9 | {{ $t('home.cta.description') }} 10 | 11 | 12 | 17 | {{ $t('home.cta.button') }} 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/home/Link.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 39 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /app/components/home/Logos.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ $t('home.logos.title') }} 5 | 6 | 7 | 12 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/home/Twitter.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 16 | 20 | {{ $t('home.twitter.follow') }} 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/components/ui/accordion/Accordion.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/ui/accordion/AccordionContent.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/accordion/AccordionItem.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/accordion/AccordionTrigger.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | 22 | 31 | 32 | 33 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/components/ui/accordion/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Accordion } from './Accordion.vue' 2 | export { default as AccordionContent } from './AccordionContent.vue' 3 | export { default as AccordionItem } from './AccordionItem.vue' 4 | export { default as AccordionTrigger } from './AccordionTrigger.vue' 5 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialog.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogAction.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogCancel.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogDescription.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogTitle.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AlertDialog } from './AlertDialog.vue' 2 | export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue' 3 | export { default as AlertDialogContent } from './AlertDialogContent.vue' 4 | export { default as AlertDialogHeader } from './AlertDialogHeader.vue' 5 | export { default as AlertDialogTitle } from './AlertDialogTitle.vue' 6 | export { default as AlertDialogDescription } from './AlertDialogDescription.vue' 7 | export { default as AlertDialogFooter } from './AlertDialogFooter.vue' 8 | export { default as AlertDialogAction } from './AlertDialogAction.vue' 9 | export { default as AlertDialogCancel } from './AlertDialogCancel.vue' 10 | -------------------------------------------------------------------------------- /app/components/ui/alert/Alert.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/components/ui/alert/AlertDescription.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/alert/AlertTitle.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/alert/index.ts: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from 'class-variance-authority' 2 | 3 | export { default as Alert } from './Alert.vue' 4 | export { default as AlertTitle } from './AlertTitle.vue' 5 | export { default as AlertDescription } from './AlertDescription.vue' 6 | 7 | export const alertVariants = cva( 8 | 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-background text-foreground', 13 | destructive: 14 | 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', 15 | }, 16 | }, 17 | defaultVariants: { 18 | variant: 'default', 19 | }, 20 | }, 21 | ) 22 | 23 | export type AlertVariants = VariantProps 24 | -------------------------------------------------------------------------------- /app/components/ui/aspect-ratio/AspectRatio.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/aspect-ratio/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AspectRatio } from './AspectRatio.vue' 2 | -------------------------------------------------------------------------------- /app/components/ui/auto-form/AutoFormField.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/components/ui/auto-form/AutoFormFieldInput.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | 17 | 18 | {{ config?.label || beautifyObjectName(label ?? fieldName) }} 19 | 20 | 21 | 22 | 28 | 29 | 30 | 31 | {{ config.description }} 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/components/ui/auto-form/AutoFormFieldNumber.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | {{ config?.label || beautifyObjectName(label ?? fieldName) }} 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {{ config.description }} 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/components/ui/auto-form/AutoFormLabel.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | * 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/auto-form/constant.ts: -------------------------------------------------------------------------------- 1 | import AutoFormFieldArray from './AutoFormFieldArray.vue' 2 | import AutoFormFieldBoolean from './AutoFormFieldBoolean.vue' 3 | import AutoFormFieldDate from './AutoFormFieldDate.vue' 4 | import AutoFormFieldEnum from './AutoFormFieldEnum.vue' 5 | import AutoFormFieldFile from './AutoFormFieldFile.vue' 6 | import AutoFormFieldInput from './AutoFormFieldInput.vue' 7 | import AutoFormFieldNumber from './AutoFormFieldNumber.vue' 8 | import AutoFormFieldObject from './AutoFormFieldObject.vue' 9 | 10 | export const INPUT_COMPONENTS = { 11 | date: AutoFormFieldDate, 12 | select: AutoFormFieldEnum, 13 | radio: AutoFormFieldEnum, 14 | checkbox: AutoFormFieldBoolean, 15 | switch: AutoFormFieldBoolean, 16 | textarea: AutoFormFieldInput, 17 | number: AutoFormFieldNumber, 18 | string: AutoFormFieldInput, 19 | file: AutoFormFieldFile, 20 | array: AutoFormFieldArray, 21 | object: AutoFormFieldObject, 22 | } 23 | 24 | /** 25 | * Define handlers for specific Zod types. 26 | * You can expand this object to support more types. 27 | */ 28 | export const DEFAULT_ZOD_HANDLERS: { 29 | [key: string]: keyof typeof INPUT_COMPONENTS 30 | } = { 31 | ZodString: 'string', 32 | ZodBoolean: 'checkbox', 33 | ZodDate: 'date', 34 | ZodEnum: 'select', 35 | ZodNativeEnum: 'select', 36 | ZodNumber: 'number', 37 | ZodArray: 'array', 38 | ZodObject: 'object', 39 | } 40 | -------------------------------------------------------------------------------- /app/components/ui/auto-form/index.ts: -------------------------------------------------------------------------------- 1 | export { getObjectFormSchema, getBaseSchema, getBaseType } from './utils' 2 | export type { Config, ConfigItem, FieldProps } from './interface' 3 | 4 | export { default as AutoForm } from './AutoForm.vue' 5 | export { default as AutoFormField } from './AutoFormField.vue' 6 | export { default as AutoFormLabel } from './AutoFormLabel.vue' 7 | 8 | export { default as AutoFormFieldArray } from './AutoFormFieldArray.vue' 9 | export { default as AutoFormFieldBoolean } from './AutoFormFieldBoolean.vue' 10 | export { default as AutoFormFieldDate } from './AutoFormFieldDate.vue' 11 | export { default as AutoFormFieldEnum } from './AutoFormFieldEnum.vue' 12 | export { default as AutoFormFieldFile } from './AutoFormFieldFile.vue' 13 | export { default as AutoFormFieldInput } from './AutoFormFieldInput.vue' 14 | export { default as AutoFormFieldNumber } from './AutoFormFieldNumber.vue' 15 | export { default as AutoFormFieldObject } from './AutoFormFieldObject.vue' 16 | -------------------------------------------------------------------------------- /app/components/ui/avatar/Avatar.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/avatar/AvatarFallback.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/avatar/AvatarImage.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/components/ui/avatar/index.ts: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from 'class-variance-authority' 2 | 3 | export { default as Avatar } from './Avatar.vue' 4 | export { default as AvatarImage } from './AvatarImage.vue' 5 | export { default as AvatarFallback } from './AvatarFallback.vue' 6 | 7 | export const avatarVariant = cva( 8 | 'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden', 9 | { 10 | variants: { 11 | size: { 12 | sm: 'h-10 w-10 text-xs', 13 | base: 'h-16 w-16 text-2xl', 14 | lg: 'h-32 w-32 text-5xl', 15 | }, 16 | shape: { 17 | circle: 'rounded-full', 18 | square: 'rounded-md', 19 | }, 20 | }, 21 | }, 22 | ) 23 | 24 | export type AvatarVariants = VariantProps 25 | -------------------------------------------------------------------------------- /app/components/ui/badge/Badge.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/components/ui/badge/index.ts: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from 'class-variance-authority' 2 | 3 | export { default as Badge } from './Badge.vue' 4 | 5 | export const badgeVariants = cva( 6 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 7 | { 8 | variants: { 9 | variant: { 10 | default: 11 | 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', 12 | secondary: 13 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 14 | destructive: 15 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', 16 | outline: 'text-foreground', 17 | }, 18 | }, 19 | defaultVariants: { 20 | variant: 'default', 21 | }, 22 | }, 23 | ) 24 | 25 | export type BadgeVariants = VariantProps 26 | -------------------------------------------------------------------------------- /app/components/ui/breadcrumb/Breadcrumb.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/components/ui/breadcrumb/BreadcrumbEllipsis.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | More 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/components/ui/breadcrumb/BreadcrumbItem.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/components/ui/breadcrumb/BreadcrumbLink.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/ui/breadcrumb/BreadcrumbList.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/components/ui/breadcrumb/BreadcrumbPage.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/ui/breadcrumb/BreadcrumbSeparator.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/breadcrumb/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Breadcrumb } from './Breadcrumb.vue' 2 | export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue' 3 | export { default as BreadcrumbItem } from './BreadcrumbItem.vue' 4 | export { default as BreadcrumbLink } from './BreadcrumbLink.vue' 5 | export { default as BreadcrumbList } from './BreadcrumbList.vue' 6 | export { default as BreadcrumbPage } from './BreadcrumbPage.vue' 7 | export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue' 8 | -------------------------------------------------------------------------------- /app/components/ui/button/Button.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | import { type VariantProps, cva } from 'class-variance-authority' 2 | 3 | export { default as Button } from './Button.vue' 4 | 5 | export const buttonVariants = cva( 6 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 7 | { 8 | variants: { 9 | variant: { 10 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 11 | destructive: 12 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 13 | outline: 14 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 15 | secondary: 16 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 17 | ghost: 'hover:bg-accent hover:text-accent-foreground', 18 | link: 'text-primary underline-offset-4 hover:underline', 19 | }, 20 | size: { 21 | default: 'h-10 px-4 py-2', 22 | xs: 'h-7 rounded px-2', 23 | sm: 'h-9 rounded-md px-3', 24 | lg: 'h-11 rounded-md px-8', 25 | icon: 'h-10 w-10', 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: 'default', 30 | size: 'default', 31 | }, 32 | }, 33 | ) 34 | 35 | export type ButtonVariants = VariantProps 36 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarCell.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarGrid.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarGridBody.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarGridHead.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarGridRow.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarHeadCell.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarHeader.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarHeading.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 23 | 24 | {{ headingValue }} 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarNextButton.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/components/ui/calendar/CalendarPrevButton.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/components/ui/calendar/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Calendar } from './Calendar.vue' 2 | export { default as CalendarCell } from './CalendarCell.vue' 3 | export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue' 4 | export { default as CalendarGrid } from './CalendarGrid.vue' 5 | export { default as CalendarGridBody } from './CalendarGridBody.vue' 6 | export { default as CalendarGridHead } from './CalendarGridHead.vue' 7 | export { default as CalendarGridRow } from './CalendarGridRow.vue' 8 | export { default as CalendarHeadCell } from './CalendarHeadCell.vue' 9 | export { default as CalendarHeader } from './CalendarHeader.vue' 10 | export { default as CalendarHeading } from './CalendarHeading.vue' 11 | export { default as CalendarNextButton } from './CalendarNextButton.vue' 12 | export { default as CalendarPrevButton } from './CalendarPrevButton.vue' 13 | -------------------------------------------------------------------------------- /app/components/ui/card/Card.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/card/CardContent.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/card/CardDescription.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/card/CardFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/card/CardHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/card/CardTitle.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/components/ui/card/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Card } from './Card.vue' 2 | export { default as CardHeader } from './CardHeader.vue' 3 | export { default as CardTitle } from './CardTitle.vue' 4 | export { default as CardDescription } from './CardDescription.vue' 5 | export { default as CardContent } from './CardContent.vue' 6 | export { default as CardFooter } from './CardFooter.vue' 7 | -------------------------------------------------------------------------------- /app/components/ui/chart-area/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AreaChart } from './AreaChart.vue' 2 | -------------------------------------------------------------------------------- /app/components/ui/chart-bar/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BarChart } from './BarChart.vue' 2 | -------------------------------------------------------------------------------- /app/components/ui/chart/ChartTooltip.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | 20 | 21 | {{ title }} 22 | 23 | 24 | 25 | 30 | 31 | 32 | 37 | 43 | 44 | 45 | {{ item.name }} 46 | 47 | {{ item.value }} 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /app/components/ui/chart/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ChartTooltip } from './ChartTooltip.vue' 2 | export { default as ChartSingleTooltip } from './ChartSingleTooltip.vue' 3 | export { default as ChartLegend } from './ChartLegend.vue' 4 | export { default as ChartCrosshair } from './ChartCrosshair.vue' 5 | 6 | export function defaultColors(count: number = 3) { 7 | const quotient = Math.floor(count / 2) 8 | const remainder = count % 2 9 | 10 | const primaryCount = quotient + remainder 11 | const secondaryCount = quotient 12 | return [ 13 | ...Array.from(Array(primaryCount).keys()).map(i => `hsl(var(--vis-primary-color) / ${1 - (1 / primaryCount) * i})`), 14 | ...Array.from(Array(secondaryCount).keys()).map(i => `hsl(var(--vis-secondary-color) / ${1 - (1 / secondaryCount) * i})`), 15 | ] 16 | } 17 | 18 | export * from './interface' 19 | -------------------------------------------------------------------------------- /app/components/ui/checkbox/Checkbox.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/components/ui/checkbox/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Checkbox } from './Checkbox.vue' 2 | -------------------------------------------------------------------------------- /app/components/ui/command/Command.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/components/ui/command/CommandDialog.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/command/CommandEmpty.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/components/ui/command/CommandGroup.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 24 | 25 | {{ heading }} 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/components/ui/command/CommandInput.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 26 | 27 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/components/ui/command/CommandItem.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/components/ui/command/CommandList.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/components/ui/command/CommandSeparator.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/components/ui/command/CommandShortcut.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/command/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Command } from './Command.vue' 2 | export { default as CommandDialog } from './CommandDialog.vue' 3 | export { default as CommandEmpty } from './CommandEmpty.vue' 4 | export { default as CommandGroup } from './CommandGroup.vue' 5 | export { default as CommandInput } from './CommandInput.vue' 6 | export { default as CommandItem } from './CommandItem.vue' 7 | export { default as CommandList } from './CommandList.vue' 8 | export { default as CommandSeparator } from './CommandSeparator.vue' 9 | export { default as CommandShortcut } from './CommandShortcut.vue' 10 | -------------------------------------------------------------------------------- /app/components/ui/dialog/Dialog.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogClose.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogDescription.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogFooter.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogTitle.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/dialog/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Dialog } from './Dialog.vue' 2 | export { default as DialogClose } from './DialogClose.vue' 3 | export { default as DialogTrigger } from './DialogTrigger.vue' 4 | export { default as DialogHeader } from './DialogHeader.vue' 5 | export { default as DialogTitle } from './DialogTitle.vue' 6 | export { default as DialogDescription } from './DialogDescription.vue' 7 | export { default as DialogContent } from './DialogContent.vue' 8 | export { default as DialogScrollContent } from './DialogScrollContent.vue' 9 | export { default as DialogFooter } from './DialogFooter.vue' 10 | -------------------------------------------------------------------------------- /app/components/ui/drawer/Drawer.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/ui/drawer/DrawerContent.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/components/ui/drawer/DrawerDescription.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/drawer/DrawerFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/drawer/DrawerHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/drawer/DrawerOverlay.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/ui/drawer/DrawerTitle.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/drawer/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Drawer } from './Drawer.vue' 2 | export { default as DrawerContent } from './DrawerContent.vue' 3 | export { default as DrawerDescription } from './DrawerDescription.vue' 4 | export { default as DrawerFooter } from './DrawerFooter.vue' 5 | export { default as DrawerHeader } from './DrawerHeader.vue' 6 | export { default as DrawerOverlay } from './DrawerOverlay.vue' 7 | export { default as DrawerTitle } from './DrawerTitle.vue' 8 | export { DrawerClose, DrawerPortal, DrawerTrigger } from 'vaul-vue' 9 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenu.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuContent.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | 31 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuGroup.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuItem.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuLabel.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 27 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuSeparator.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuShortcut.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuSub.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuSubContent.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuTrigger.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/index.ts: -------------------------------------------------------------------------------- 1 | export { DropdownMenuPortal } from 'radix-vue' 2 | 3 | export { default as DropdownMenu } from './DropdownMenu.vue' 4 | export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue' 5 | export { default as DropdownMenuContent } from './DropdownMenuContent.vue' 6 | export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue' 7 | export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue' 8 | export { default as DropdownMenuItem } from './DropdownMenuItem.vue' 9 | export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue' 10 | export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue' 11 | export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue' 12 | export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue' 13 | export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue' 14 | export { default as DropdownMenuSub } from './DropdownMenuSub.vue' 15 | export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue' 16 | export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue' 17 | -------------------------------------------------------------------------------- /app/components/ui/form/FormControl.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/components/ui/form/FormDescription.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/components/ui/form/FormItem.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/components/ui/form/FormLabel.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/components/ui/form/FormMessage.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 16 | 17 | -------------------------------------------------------------------------------- /app/components/ui/form/index.ts: -------------------------------------------------------------------------------- 1 | export { Form, Field as FormField } from 'vee-validate' 2 | export { default as FormItem } from './FormItem.vue' 3 | export { default as FormLabel } from './FormLabel.vue' 4 | export { default as FormControl } from './FormControl.vue' 5 | export { default as FormMessage } from './FormMessage.vue' 6 | export { default as FormDescription } from './FormDescription.vue' 7 | -------------------------------------------------------------------------------- /app/components/ui/form/useFormField.ts: -------------------------------------------------------------------------------- 1 | import { FieldContextKey, useFieldError, useIsFieldDirty, useIsFieldTouched, useIsFieldValid } from 'vee-validate' 2 | import { inject } from 'vue' 3 | import { FORM_ITEM_INJECTION_KEY } from '~/composables' 4 | 5 | export function useFormField() { 6 | const fieldContext = inject(FieldContextKey) 7 | const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY) 8 | 9 | const fieldState = { 10 | valid: useIsFieldValid(), 11 | isDirty: useIsFieldDirty(), 12 | isTouched: useIsFieldTouched(), 13 | error: useFieldError(), 14 | } 15 | 16 | if (!fieldContext) 17 | throw new Error('useFormField should be used within ') 18 | 19 | const { name } = fieldContext 20 | const id = fieldItemContext 21 | 22 | return { 23 | id, 24 | name, 25 | formItemId: `${id}-form-item`, 26 | formDescriptionId: `${id}-form-item-description`, 27 | formMessageId: `${id}-form-item-message`, 28 | ...fieldState, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/components/ui/hover-card/HoverCard.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/hover-card/HoverCardContent.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | 29 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /app/components/ui/hover-card/HoverCardTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/hover-card/index.ts: -------------------------------------------------------------------------------- 1 | export { default as HoverCard } from './HoverCard.vue' 2 | export { default as HoverCardTrigger } from './HoverCardTrigger.vue' 3 | export { default as HoverCardContent } from './HoverCardContent.vue' 4 | -------------------------------------------------------------------------------- /app/components/ui/input/Input.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/input/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Input } from './Input.vue' 2 | -------------------------------------------------------------------------------- /app/components/ui/label/Label.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/components/ui/label/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Label } from './Label.vue' 2 | -------------------------------------------------------------------------------- /app/components/ui/menubar/Menubar.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarCheckboxItem.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarContent.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | 31 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarGroup.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarItem.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarLabel.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarMenu.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarRadioGroup.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarRadioItem.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarSeparator.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarShortcut.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarSub.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarSubContent.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarSubTrigger.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/components/ui/menubar/MenubarTrigger.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/components/ui/menubar/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Menubar } from './Menubar.vue' 2 | export { default as MenubarItem } from './MenubarItem.vue' 3 | export { default as MenubarContent } from './MenubarContent.vue' 4 | export { default as MenubarGroup } from './MenubarGroup.vue' 5 | export { default as MenubarMenu } from './MenubarMenu.vue' 6 | export { default as MenubarRadioGroup } from './MenubarRadioGroup.vue' 7 | export { default as MenubarRadioItem } from './MenubarRadioItem.vue' 8 | export { default as MenubarCheckboxItem } from './MenubarCheckboxItem.vue' 9 | export { default as MenubarSeparator } from './MenubarSeparator.vue' 10 | export { default as MenubarSub } from './MenubarSub.vue' 11 | export { default as MenubarSubContent } from './MenubarSubContent.vue' 12 | export { default as MenubarSubTrigger } from './MenubarSubTrigger.vue' 13 | export { default as MenubarTrigger } from './MenubarTrigger.vue' 14 | export { default as MenubarShortcut } from './MenubarShortcut.vue' 15 | export { default as MenubarLabel } from './MenubarLabel.vue' 16 | -------------------------------------------------------------------------------- /app/components/ui/navigation-menu/NavigationMenu.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/components/ui/navigation-menu/NavigationMenuContent.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/components/ui/navigation-menu/NavigationMenuIndicator.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/navigation-menu/NavigationMenuItem.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/navigation-menu/NavigationMenuLink.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/ui/navigation-menu/NavigationMenuList.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/components/ui/navigation-menu/NavigationMenuTrigger.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | 28 | 29 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/components/ui/navigation-menu/NavigationMenuViewport.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 23 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/components/ui/navigation-menu/index.ts: -------------------------------------------------------------------------------- 1 | import { cva } from 'class-variance-authority' 2 | 3 | export { default as NavigationMenu } from './NavigationMenu.vue' 4 | export { default as NavigationMenuList } from './NavigationMenuList.vue' 5 | export { default as NavigationMenuItem } from './NavigationMenuItem.vue' 6 | export { default as NavigationMenuTrigger } from './NavigationMenuTrigger.vue' 7 | export { default as NavigationMenuContent } from './NavigationMenuContent.vue' 8 | export { default as NavigationMenuLink } from './NavigationMenuLink.vue' 9 | 10 | export const navigationMenuTriggerStyle = cva( 11 | 'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50', 12 | ) 13 | -------------------------------------------------------------------------------- /app/components/ui/popover/Popover.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/components/ui/popover/PopoverTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/popover/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Popover } from './Popover.vue' 2 | export { default as PopoverTrigger } from './PopoverTrigger.vue' 3 | export { default as PopoverContent } from './PopoverContent.vue' 4 | -------------------------------------------------------------------------------- /app/components/ui/progress/Progress.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 34 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/components/ui/progress/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Progress } from './Progress.vue' 2 | -------------------------------------------------------------------------------- /app/components/ui/radio-group/RadioGroup.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/components/ui/radio-group/RadioGroupItem.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | 33 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /app/components/ui/radio-group/index.ts: -------------------------------------------------------------------------------- 1 | export { default as RadioGroup } from './RadioGroup.vue' 2 | export { default as RadioGroupItem } from './RadioGroupItem.vue' 3 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarCell.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarGrid.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarGridBody.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarGridHead.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarGridRow.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarHeadCell.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarHeader.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarHeading.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 23 | 24 | {{ headingValue }} 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarNextButton.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/RangeCalendarPrevButton.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/components/ui/range-calendar/index.ts: -------------------------------------------------------------------------------- 1 | export { default as RangeCalendar } from './RangeCalendar.vue' 2 | export { default as RangeCalendarCell } from './RangeCalendarCell.vue' 3 | export { default as RangeCalendarCellTrigger } from './RangeCalendarCellTrigger.vue' 4 | export { default as RangeCalendarGrid } from './RangeCalendarGrid.vue' 5 | export { default as RangeCalendarGridBody } from './RangeCalendarGridBody.vue' 6 | export { default as RangeCalendarGridHead } from './RangeCalendarGridHead.vue' 7 | export { default as RangeCalendarGridRow } from './RangeCalendarGridRow.vue' 8 | export { default as RangeCalendarHeadCell } from './RangeCalendarHeadCell.vue' 9 | export { default as RangeCalendarHeader } from './RangeCalendarHeader.vue' 10 | export { default as RangeCalendarHeading } from './RangeCalendarHeading.vue' 11 | export { default as RangeCalendarNextButton } from './RangeCalendarNextButton.vue' 12 | export { default as RangeCalendarPrevButton } from './RangeCalendarPrevButton.vue' 13 | -------------------------------------------------------------------------------- /app/components/ui/select/Select.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectGroup.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectItem.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectItemText.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectLabel.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectScrollDownButton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectScrollUpButton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectSeparator.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectTrigger.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/components/ui/select/SelectValue.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/select/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Select } from './Select.vue' 2 | export { default as SelectValue } from './SelectValue.vue' 3 | export { default as SelectTrigger } from './SelectTrigger.vue' 4 | export { default as SelectContent } from './SelectContent.vue' 5 | export { default as SelectGroup } from './SelectGroup.vue' 6 | export { default as SelectItem } from './SelectItem.vue' 7 | export { default as SelectItemText } from './SelectItemText.vue' 8 | export { default as SelectLabel } from './SelectLabel.vue' 9 | export { default as SelectSeparator } from './SelectSeparator.vue' 10 | export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue' 11 | export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue' 12 | -------------------------------------------------------------------------------- /app/components/ui/separator/Separator.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 20 | 21 | -------------------------------------------------------------------------------- /app/components/ui/separator/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Separator } from './Separator.vue' 2 | -------------------------------------------------------------------------------- /app/components/ui/skeleton/Skeleton.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/skeleton/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Skeleton } from './Skeleton.vue' 2 | -------------------------------------------------------------------------------- /app/components/ui/sonner/Sonner.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 23 | 24 | -------------------------------------------------------------------------------- /app/components/ui/sonner/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Toaster } from './Sonner.vue' 2 | -------------------------------------------------------------------------------- /app/components/ui/switch/Switch.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 33 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/components/ui/switch/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Switch } from './Switch.vue' 2 | -------------------------------------------------------------------------------- /app/components/ui/table/Table.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/components/ui/table/TableBody.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/table/TableCaption.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/table/TableCell.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/ui/table/TableEmpty.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 23 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/components/ui/table/TableFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/table/TableHead.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/table/TableHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/table/TableRow.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/table/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Table } from './Table.vue' 2 | export { default as TableBody } from './TableBody.vue' 3 | export { default as TableCell } from './TableCell.vue' 4 | export { default as TableHead } from './TableHead.vue' 5 | export { default as TableHeader } from './TableHeader.vue' 6 | export { default as TableRow } from './TableRow.vue' 7 | export { default as TableCaption } from './TableCaption.vue' 8 | export { default as TableEmpty } from './TableEmpty.vue' 9 | -------------------------------------------------------------------------------- /app/components/ui/tabs/Tabs.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/components/ui/tabs/TabsContent.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/components/ui/tabs/TabsList.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/components/ui/tabs/TabsTrigger.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/components/ui/tabs/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Tabs } from './Tabs.vue' 2 | export { default as TabsTrigger } from './TabsTrigger.vue' 3 | export { default as TabsList } from './TabsList.vue' 4 | export { default as TabsContent } from './TabsContent.vue' 5 | -------------------------------------------------------------------------------- /app/components/ui/textarea/Textarea.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/components/ui/textarea/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Textarea } from './Textarea.vue' 2 | -------------------------------------------------------------------------------- /app/components/ui/tooltip/Tooltip.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/components/ui/tooltip/TooltipContent.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/components/ui/tooltip/TooltipProvider.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/tooltip/TooltipTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/components/ui/tooltip/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Tooltip } from './Tooltip.vue' 2 | export { default as TooltipContent } from './TooltipContent.vue' 3 | export { default as TooltipTrigger } from './TooltipTrigger.vue' 4 | export { default as TooltipProvider } from './TooltipProvider.vue' 5 | -------------------------------------------------------------------------------- /app/composables/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line symbol-description 2 | export const FORM_ITEM_INJECTION_KEY = Symbol() as InjectionKey 3 | -------------------------------------------------------------------------------- /app/error.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/middleware/auth.global.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async (to) => { 2 | if (import.meta.server) 3 | return 4 | 5 | if (to.path.startsWith('/dashboard') && to.path !== '/dashboard/login') { 6 | if (!window.localStorage.getItem('SinkSiteToken')) 7 | return navigateTo('/dashboard/login') 8 | } 9 | 10 | if (to.path === '/dashboard/login') { 11 | try { 12 | await useAPI('/api/verify') 13 | return navigateTo('/dashboard') 14 | } 15 | catch (e) { 16 | console.warn(e) 17 | } 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /app/pages/dashboard/analysis.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/pages/dashboard/link.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 33 | 34 | 35 | 40 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/pages/dashboard/links.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/pages/dashboard/login.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/pages/dashboard/realtime.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/utils/api.ts: -------------------------------------------------------------------------------- 1 | import { defu } from 'defu' 2 | import { toast } from 'vue-sonner' 3 | 4 | export function useAPI(api: string, options?: object): Promise { 5 | return $fetch(api, defu(options || {}, { 6 | headers: { 7 | Authorization: `Bearer ${localStorage.getItem('SinkSiteToken') || ''}`, 8 | }, 9 | })).catch((error) => { 10 | if (error?.status === 401) { 11 | localStorage.removeItem('SinkSiteToken') 12 | navigateTo('/dashboard/login') 13 | } 14 | if (error?.data?.statusMessage) { 15 | toast(error?.data?.statusMessage) 16 | } 17 | return Promise.reject(error) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /app/utils/color.ts: -------------------------------------------------------------------------------- 1 | export function colorGradation(count: number) { 2 | return Array.from({ length: count }, (_, i) => `hsl(var(--vis-primary-color) / ${1 - (1 / count) * i})`) 3 | } 4 | -------------------------------------------------------------------------------- /app/utils/events.ts: -------------------------------------------------------------------------------- 1 | import { useEventBus } from '@vueuse/core' 2 | 3 | export const globalTrafficEvent = useEventBus(Symbol('traffic')) 4 | -------------------------------------------------------------------------------- /app/utils/flag.ts: -------------------------------------------------------------------------------- 1 | const EMOJI_FLAG_UNICODE_STARTING_POSITION = 127397 2 | 3 | export function getFlag(countryCode: string) { 4 | const regex = /^[A-Z]{2}$/.test(countryCode) 5 | if (!countryCode || !regex) 6 | return void 0 7 | return String.fromCodePoint(...countryCode.split('').map(char => EMOJI_FLAG_UNICODE_STARTING_POSITION + char.charCodeAt(0))) 8 | } 9 | -------------------------------------------------------------------------------- /app/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { ClassValue } from 'clsx' 2 | import { clsx } from 'clsx' 3 | import { twMerge } from 'tailwind-merge' 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)) 7 | } 8 | -------------------------------------------------------------------------------- /app/utils/number.ts: -------------------------------------------------------------------------------- 1 | export function formatNumber(number: number) { 2 | if (!number || typeof Intl === 'undefined') 3 | return number 4 | 5 | return new Intl.NumberFormat('en').format(number) 6 | } 7 | -------------------------------------------------------------------------------- /app/utils/time.ts: -------------------------------------------------------------------------------- 1 | import type { DateValue } from '@internationalized/date' 2 | import { fromAbsolute, toCalendarDate } from '@internationalized/date' 3 | 4 | export function getTimeZone() { 5 | if (typeof Intl === 'undefined') 6 | return 'Etc/UTC' 7 | 8 | return Intl.DateTimeFormat().resolvedOptions().timeZone 9 | } 10 | 11 | export function getLocale() { 12 | if (typeof Intl === 'undefined') 13 | return navigator.language 14 | 15 | return Intl.DateTimeFormat().resolvedOptions().locale 16 | } 17 | 18 | export function shortDate(unix = 0) { 19 | const shortDate = new Intl.DateTimeFormat(undefined, { 20 | dateStyle: 'short', 21 | }) 22 | return shortDate.format(unix * 1000) 23 | } 24 | 25 | export function longDate(unix = 0) { 26 | return new Date(unix * 1000).toLocaleString() 27 | } 28 | 29 | export function shortTime(unix = 0) { 30 | const shortTime = new Intl.DateTimeFormat(undefined, { 31 | timeStyle: 'short', 32 | }) 33 | return shortTime.format(unix * 1000) 34 | } 35 | 36 | export function date2unix(dateValue: DateValue | Date, type?: string) { 37 | const date = dateValue instanceof Date ? dateValue : dateValue.toDate(getTimeZone()) 38 | if (type === 'start') 39 | return Math.floor(date.setHours(0, 0, 0) / 1000) 40 | 41 | if (type === 'end') 42 | return Math.floor(date.setHours(23, 59, 59) / 1000) 43 | 44 | return Math.floor(date.getTime() / 1000) 45 | } 46 | 47 | export function unix2date(unix: number) { 48 | return toCalendarDate(fromAbsolute(unix * 1000, getTimeZone())) 49 | } 50 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://shadcn-vue.com/schema.json", 3 | "style": "default", 4 | "typescript": true, 5 | "tailwind": { 6 | "config": "tailwind.config.js", 7 | "css": "assets/css/tailwind.css", 8 | "baseColor": "zinc", 9 | "cssVariables": true 10 | }, 11 | "aliases": { 12 | "components": "@/components", 13 | "utils": "@/utils", 14 | "ui": "@/components/ui", 15 | "lib": "@/lib", 16 | "composables": "@/composables" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/images/faqs-Analytics_engine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/docs/images/faqs-Analytics_engine.png -------------------------------------------------------------------------------- /docs/images/faqs-kv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/docs/images/faqs-kv.png -------------------------------------------------------------------------------- /docs/images/sink.cool_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/docs/images/sink.cool_dashboard.png -------------------------------------------------------------------------------- /docs/images/sink.cool_dashboard_link_slug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/docs/images/sink.cool_dashboard_link_slug.png -------------------------------------------------------------------------------- /docs/images/sink.cool_dashboard_links.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/docs/images/sink.cool_dashboard_links.png -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import antfu from '@antfu/eslint-config' 3 | import withNuxt from './.nuxt/eslint.config.mjs' 4 | 5 | export default withNuxt( 6 | antfu(), 7 | { 8 | ignores: ['app/components/ui', '.data', 'public/*.json'], 9 | }, 10 | { 11 | rules: { 12 | '@typescript-eslint/ban-ts-comment': 'off', 13 | 'no-console': 'off', 14 | 'node/prefer-global/process': 'off', 15 | 'vue/no-v-html': 'off', 16 | }, 17 | }, 18 | ) 19 | -------------------------------------------------------------------------------- /i18n/i18n.config.ts: -------------------------------------------------------------------------------- 1 | import { currentLocales } from './i18n' 2 | 3 | export default defineI18nConfig(() => { 4 | return { 5 | legacy: false, 6 | availableLocales: currentLocales.map(l => l.code), 7 | fallbackLocale: 'en-US', 8 | fallbackWarn: true, 9 | missingWarn: true, 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /i18n/i18n.ts: -------------------------------------------------------------------------------- 1 | import type { LocaleObject } from '@nuxtjs/i18n' 2 | 3 | const locales: LocaleObject[] = [ 4 | { 5 | code: 'en-US', 6 | file: 'en-US.json', 7 | name: 'English', 8 | emoji: '🇺🇸', 9 | }, 10 | { 11 | code: 'zh-CN', 12 | file: 'zh-CN.json', 13 | name: '简体中文', 14 | emoji: '🇨🇳', 15 | }, 16 | { 17 | code: 'zh-TW', 18 | file: 'zh-TW.json', 19 | name: '繁體中文', 20 | emoji: '🇹🇼', 21 | }, 22 | { 23 | code: 'fr-FR', 24 | file: 'fr-FR.json', 25 | name: 'Français', 26 | emoji: '🇫🇷', 27 | }, 28 | { 29 | code: 'vi-VN', 30 | file: 'vi-VN.json', 31 | name: 'Tiếng Việt', 32 | emoji: '🇻🇳', 33 | }, 34 | { 35 | code: 'de-DE', 36 | file: 'de-DE.json', 37 | name: 'Deutsch', 38 | emoji: '🇩🇪', 39 | }, 40 | ] 41 | 42 | function buildLocales() { 43 | const useLocales = Object.values(locales).reduce((acc, data) => { 44 | acc.push(data) 45 | 46 | return acc 47 | }, []) 48 | 49 | return useLocales.sort((a, b) => a.code.localeCompare(b.code)) 50 | } 51 | 52 | export const currentLocales = buildLocales() 53 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - esbuild 3 | - sharp 4 | - simple-git-hooks 5 | - unrs-resolver 6 | - vue-demi 7 | - workerd 8 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/public/banner.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/public/favicon.ico -------------------------------------------------------------------------------- /public/icon-192-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/public/icon-192-maskable.png -------------------------------------------------------------------------------- /public/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/public/icon-192.png -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/public/icon.png -------------------------------------------------------------------------------- /public/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/public/image.png -------------------------------------------------------------------------------- /public/sink-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/public/sink-1024.png -------------------------------------------------------------------------------- /public/sink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccbikai/Sink/3438170d954a2a5bc513fc4d0aa5f687e36d0f34/public/sink.png -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sink", 3 | "short_name": "Sink", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>nuxt/renovate-config-nuxt" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /schemas/link.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid' 2 | import { z } from 'zod' 3 | 4 | const { slugRegex } = useAppConfig() 5 | 6 | const slugDefaultLength = +useRuntimeConfig().public.slugDefaultLength 7 | 8 | export const nanoid = (length: number = slugDefaultLength) => customAlphabet('23456789abcdefghjkmnpqrstuvwxyz', length) 9 | 10 | export const LinkSchema = z.object({ 11 | id: z.string().trim().max(26).default(nanoid(10)), 12 | url: z.string().trim().url().max(2048), 13 | slug: z.string().trim().max(2048).regex(new RegExp(slugRegex)).default(nanoid()), 14 | comment: z.string().trim().max(2048).optional(), 15 | createdAt: z.number().int().safe().default(() => Math.floor(Date.now() / 1000)), 16 | updatedAt: z.number().int().safe().default(() => Math.floor(Date.now() / 1000)), 17 | expiration: z.number().int().safe().refine(expiration => expiration > Math.floor(Date.now() / 1000), { 18 | message: 'expiration must be greater than current time', 19 | path: ['expiration'], // 这里指定错误消息关联到哪个字段 20 | }).optional(), 21 | title: z.string().trim().max(2048).optional(), 22 | description: z.string().trim().max(2048).optional(), 23 | image: z.string().trim().url().max(2048).optional(), 24 | }) 25 | -------------------------------------------------------------------------------- /schemas/query.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const listQueryLimit = +useRuntimeConfig().listQueryLimit 4 | 5 | export const QuerySchema = z.object({ 6 | id: z.string().optional(), 7 | startAt: z.coerce.number().int().safe().optional(), 8 | endAt: z.coerce.number().int().safe().optional(), 9 | url: z.string().optional(), 10 | slug: z.string().optional(), 11 | referer: z.string().optional(), 12 | country: z.string().optional(), 13 | region: z.string().optional(), 14 | city: z.string().optional(), 15 | timezone: z.string().optional(), 16 | language: z.string().optional(), 17 | os: z.string().optional(), 18 | browser: z.string().optional(), 19 | browserType: z.string().optional(), 20 | device: z.string().optional(), 21 | deviceType: z.string().optional(), 22 | limit: z.coerce.number().int().safe().default(listQueryLimit), 23 | }) 24 | 25 | // export const FilterSchema = QuerySchema.omit({ id: true, startAt: true, endAt: true, limit: true }).extend({ 26 | // index1: z.string().optional(), 27 | // }) 28 | -------------------------------------------------------------------------------- /scripts/build-colo.js: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'node:fs' 2 | import { join } from 'node:path' 3 | 4 | async function main() { 5 | const locations = await fetch('https://raw.githubusercontent.com/Netrvin/cloudflare-colo-list/refs/heads/main/locations.json') 6 | if (!locations.ok) { 7 | throw new Error('Failed to fetch locations') 8 | } 9 | const colos = await locations.json() 10 | writeFileSync(join(import.meta.dirname, '../public/colos.json'), JSON.stringify(colos.reduce((acc, c) => { 11 | acc[c.iata] = { 12 | lat: c.lat, 13 | lon: c.lon, 14 | } 15 | return acc 16 | }, {}), 'utf8')) 17 | } 18 | 19 | main().catch(console.error) 20 | -------------------------------------------------------------------------------- /scripts/build-map.js: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'node:fs' 2 | import { join } from 'node:path' 3 | import { WorldMapSimplestTopoJSON } from '@unovis/ts/maps.js' 4 | 5 | writeFileSync(join(import.meta.dirname, '../public/world.json'), JSON.stringify(WorldMapSimplestTopoJSON), 'utf8') 6 | -------------------------------------------------------------------------------- /server/api/link/ai.get.ts: -------------------------------------------------------------------------------- 1 | import { destr } from 'destr' 2 | import { z } from 'zod' 3 | 4 | export default eventHandler(async (event) => { 5 | const url = (await getValidatedQuery(event, z.object({ 6 | url: z.string().url(), 7 | }).parse)).url 8 | const { cloudflare } = event.context 9 | const { AI } = cloudflare.env 10 | 11 | if (AI) { 12 | const { aiPrompt, aiModel } = useRuntimeConfig(event) 13 | const { slugRegex } = useAppConfig(event) 14 | const messages = [ 15 | { role: 'system', content: aiPrompt.replace('{slugRegex}', slugRegex.toString()) }, 16 | 17 | { role: 'user', content: 'https://www.cloudflare.com/' }, 18 | { role: 'assistant', content: '{"slug": "cloudflare"}' }, 19 | 20 | { role: 'user', content: 'https://github.com/nuxt-hub/' }, 21 | { role: 'assistant', content: '{"slug": "nuxt-hub"}' }, 22 | 23 | { role: 'user', content: 'https://sink.cool/' }, 24 | { role: 'assistant', content: '{"slug": "sink-cool"}' }, 25 | 26 | { role: 'user', content: 'https://github.com/ccbikai/sink' }, 27 | { role: 'assistant', content: '{"slug": "sink"}' }, 28 | 29 | { 30 | role: 'user', 31 | content: url, 32 | }, 33 | ] 34 | // @ts-expect-error Workers AI is not typed 35 | const { response } = await hubAI().run(aiModel, { messages }) 36 | return destr(response) 37 | } 38 | else { 39 | throw createError({ status: 501, statusText: 'AI not enabled' }) 40 | } 41 | }) 42 | -------------------------------------------------------------------------------- /server/api/link/delete.post.ts: -------------------------------------------------------------------------------- 1 | export default eventHandler(async (event) => { 2 | const { previewMode } = useRuntimeConfig(event).public 3 | if (previewMode) { 4 | throw createError({ 5 | status: 403, 6 | statusText: 'Preview mode cannot delete links.', 7 | }) 8 | } 9 | const { slug } = await readBody(event) 10 | if (slug) { 11 | const { cloudflare } = event.context 12 | const { KV } = cloudflare.env 13 | await KV.delete(`link:${slug}`) 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /server/api/link/edit.put.ts: -------------------------------------------------------------------------------- 1 | import type { z } from 'zod' 2 | import { LinkSchema } from '@@/schemas/link' 3 | 4 | export default eventHandler(async (event) => { 5 | const { previewMode } = useRuntimeConfig(event).public 6 | if (previewMode) { 7 | throw createError({ 8 | status: 403, 9 | statusText: 'Preview mode cannot edit links.', 10 | }) 11 | } 12 | const link = await readValidatedBody(event, LinkSchema.parse) 13 | const { cloudflare } = event.context 14 | const { KV } = cloudflare.env 15 | 16 | const existingLink: z.infer | null = await KV.get(`link:${link.slug}`, { type: 'json' }) 17 | if (existingLink) { 18 | const newLink = { 19 | ...existingLink, 20 | ...link, 21 | id: existingLink.id, // don't update id 22 | createdAt: existingLink.createdAt, // don't update createdAt 23 | updatedAt: Math.floor(Date.now() / 1000), 24 | } 25 | const expiration = getExpiration(event, newLink.expiration) 26 | await KV.put(`link:${newLink.slug}`, JSON.stringify(newLink), { 27 | expiration, 28 | metadata: { 29 | expiration, 30 | url: newLink.url, 31 | comment: newLink.comment, 32 | }, 33 | }) 34 | setResponseStatus(event, 201) 35 | const shortLink = `${getRequestProtocol(event)}://${getRequestHost(event)}/${newLink.slug}` 36 | return { link: newLink, shortLink } 37 | } 38 | }) 39 | -------------------------------------------------------------------------------- /server/api/link/list.get.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export default eventHandler(async (event) => { 4 | const { cloudflare } = event.context 5 | const { KV } = cloudflare.env 6 | const { limit, cursor } = await getValidatedQuery(event, z.object({ 7 | limit: z.coerce.number().max(1024).default(20), 8 | cursor: z.string().trim().max(1024).optional(), 9 | }).parse) 10 | const list = await KV.list({ 11 | prefix: `link:`, 12 | limit, 13 | cursor: cursor || undefined, 14 | }) 15 | if (Array.isArray(list.keys)) { 16 | list.links = await Promise.all(list.keys.map(async (key: { name: string }) => { 17 | const { metadata, value: link } = await KV.getWithMetadata(key.name, { type: 'json' }) 18 | if (link) { 19 | return { 20 | ...metadata, 21 | ...link, 22 | } 23 | } 24 | return link 25 | })) 26 | } 27 | delete list.keys 28 | return list 29 | }) 30 | -------------------------------------------------------------------------------- /server/api/link/query.get.ts: -------------------------------------------------------------------------------- 1 | export default eventHandler(async (event) => { 2 | const slug = getQuery(event).slug 3 | if (slug) { 4 | const { cloudflare } = event.context 5 | const { KV } = cloudflare.env 6 | const { metadata, value: link } = await KV.getWithMetadata(`link:${slug}`, { type: 'json' }) 7 | if (link) { 8 | return { 9 | ...metadata, 10 | ...link, 11 | } 12 | } 13 | } 14 | throw createError({ 15 | status: 404, 16 | statusText: 'Not Found', 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /server/api/link/upsert.post.ts: -------------------------------------------------------------------------------- 1 | import { LinkSchema } from '@@/schemas/link' 2 | 3 | export default eventHandler(async (event) => { 4 | const link = await readValidatedBody(event, LinkSchema.parse) 5 | const { caseSensitive } = useRuntimeConfig(event) 6 | 7 | if (!caseSensitive) { 8 | link.slug = link.slug.toLowerCase() 9 | } 10 | 11 | const { cloudflare } = event.context 12 | const { KV } = cloudflare.env 13 | 14 | // Check if link exists 15 | const existingLink = await KV.get(`link:${link.slug}`, { type: 'json' }) 16 | 17 | if (existingLink) { 18 | // If link exists, return it along with the short link 19 | const shortLink = `${getRequestProtocol(event)}://${getRequestHost(event)}/${link.slug}` 20 | return { link: existingLink, shortLink, status: 'existing' } 21 | } 22 | 23 | // If link doesn't exist, create it 24 | const expiration = getExpiration(event, link.expiration) 25 | 26 | await KV.put(`link:${link.slug}`, JSON.stringify(link), { 27 | expiration, 28 | metadata: { 29 | expiration, 30 | url: link.url, 31 | comment: link.comment, 32 | }, 33 | }) 34 | 35 | setResponseStatus(event, 201) 36 | const shortLink = `${getRequestProtocol(event)}://${getRequestHost(event)}/${link.slug}` 37 | return { link, shortLink, status: 'created' } 38 | }) 39 | -------------------------------------------------------------------------------- /server/api/location.ts: -------------------------------------------------------------------------------- 1 | defineRouteMeta({ 2 | openAPI: { 3 | description: 'Get the location of the user', 4 | responses: { 5 | 200: { 6 | description: 'The location of the user', 7 | }, 8 | }, 9 | }, 10 | }) 11 | 12 | export default eventHandler((event) => { 13 | const { request: { cf } } = event.context.cloudflare 14 | return { 15 | latitude: cf.latitude, 16 | longitude: cf.longitude, 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /server/api/logs/locations.ts: -------------------------------------------------------------------------------- 1 | import type { H3Event } from 'h3' 2 | import { QuerySchema } from '@@/schemas/query' 3 | 4 | const { select, and, notEq } = SqlBricks 5 | 6 | function query2sql(query: Query, event: H3Event): string { 7 | const filter = query2filter(query) 8 | const { dataset } = useRuntimeConfig(event) 9 | const sql = select(`blob8 as ${blobsMap.blob8},double1 as ${doublesMap.double1},double2 as ${doublesMap.double2},count() as count`) 10 | .from(dataset) 11 | .where(and([notEq('double1', 0), notEq('double2', 0), filter])) 12 | .groupBy([blobsMap.blob8, doublesMap.double1, doublesMap.double2]) 13 | appendTimeFilter(sql, query) 14 | return sql.toString() 15 | } 16 | 17 | export default eventHandler(async (event) => { 18 | const query = await getValidatedQuery(event, QuerySchema.parse) 19 | const sql = query2sql(query, event) 20 | 21 | return useWAE(event, sql) 22 | }) 23 | -------------------------------------------------------------------------------- /server/api/stats/counters.get.ts: -------------------------------------------------------------------------------- 1 | import type { H3Event } from 'h3' 2 | import { QuerySchema } from '@@/schemas/query' 3 | 4 | const { select } = SqlBricks 5 | 6 | function query2sql(query: Query, event: H3Event): string { 7 | const filter = query2filter(query) 8 | const { dataset } = useRuntimeConfig(event) 9 | // visitors did not consider sampling 10 | const sql = select(`SUM(_sample_interval) as visits, COUNT(DISTINCT ${logsMap.ip}) as visitors, COUNT(DISTINCT ${logsMap.referer}) as referers`).from(dataset).where(filter) 11 | appendTimeFilter(sql, query) 12 | return sql.toString() 13 | } 14 | 15 | export default eventHandler(async (event) => { 16 | const query = await getValidatedQuery(event, QuerySchema.parse) 17 | const sql = query2sql(query, event) 18 | return useWAE(event, sql) 19 | }) 20 | -------------------------------------------------------------------------------- /server/api/stats/metrics.get.ts: -------------------------------------------------------------------------------- 1 | import type { H3Event } from 'h3' 2 | import { QuerySchema } from '@@/schemas/query' 3 | import { z } from 'zod' 4 | 5 | const { select } = SqlBricks 6 | 7 | const MetricsQuerySchema = QuerySchema.extend({ 8 | type: z.string(), 9 | }) 10 | 11 | function query2sql(query: z.infer, event: H3Event): string { 12 | const filter = query2filter(query) 13 | const { dataset } = useRuntimeConfig(event) 14 | 15 | // @ts-expect-error todo 16 | const sql = select(`${logsMap[query.type]} as name, SUM(_sample_interval) as count`).from(dataset).where(filter).groupBy('name').orderBy('count DESC').limit(query.limit) 17 | appendTimeFilter(sql, query) 18 | return sql.toString() 19 | } 20 | 21 | export default eventHandler(async (event) => { 22 | const query = await getValidatedQuery(event, MetricsQuerySchema.parse) 23 | const sql = query2sql(query, event) 24 | return useWAE(event, sql) 25 | }) 26 | -------------------------------------------------------------------------------- /server/api/stats/views.get.ts: -------------------------------------------------------------------------------- 1 | import type { H3Event } from 'h3' 2 | import { QuerySchema } from '@@/schemas/query' 3 | import { z } from 'zod' 4 | 5 | const { select } = SqlBricks 6 | 7 | const unitMap: { [x: string]: string } = { 8 | minute: '%H:%M', 9 | hour: '%Y-%m-%d %H', 10 | day: '%Y-%m-%d', 11 | } 12 | 13 | const ViewsQuerySchema = QuerySchema.extend({ 14 | unit: z.string(), 15 | clientTimezone: z.string().default('Etc/UTC'), 16 | }) 17 | 18 | function query2sql(query: z.infer, event: H3Event): string { 19 | const filter = query2filter(query) 20 | const { dataset } = useRuntimeConfig(event) 21 | const sql = select(`formatDateTime(timestamp, '${unitMap[query.unit]}', '${query.clientTimezone}') as time, SUM(_sample_interval) as visits, COUNT(DISTINCT ${logsMap.ip}) as visitors`).from(dataset).where(filter).groupBy('time').orderBy('time') 22 | appendTimeFilter(sql, query) 23 | return sql.toString() 24 | } 25 | 26 | export default eventHandler(async (event) => { 27 | const query = await getValidatedQuery(event, ViewsQuerySchema.parse) 28 | const sql = query2sql(query, event) 29 | return useWAE(event, sql) 30 | }) 31 | -------------------------------------------------------------------------------- /server/api/verify.ts: -------------------------------------------------------------------------------- 1 | defineRouteMeta({ 2 | openAPI: { 3 | description: 'Verify the site token', 4 | responses: { 5 | 200: { 6 | description: 'The site token is valid', 7 | }, 8 | default: { 9 | description: 'The site token is invalid', 10 | }, 11 | }, 12 | }, 13 | }) 14 | 15 | export default eventHandler(() => { 16 | return { 17 | name: 'Sink', 18 | url: 'https://sink.cool', 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /server/middleware/2.auth.ts: -------------------------------------------------------------------------------- 1 | export default eventHandler((event) => { 2 | const token = getHeader(event, 'Authorization')?.replace(/^Bearer\s+/, '') 3 | if (event.path.startsWith('/api/') && !event.path.startsWith('/api/_') && token !== useRuntimeConfig(event).siteToken) { 4 | throw createError({ 5 | status: 401, 6 | statusText: 'Unauthorized', 7 | }) 8 | } 9 | if (token && token.length < 8) { 10 | throw createError({ 11 | status: 401, 12 | statusText: 'Token is too short', 13 | }) 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /server/utils/cloudflare.ts: -------------------------------------------------------------------------------- 1 | import type { H3Event } from 'h3' 2 | 3 | export function useWAE(event: H3Event, query: string) { 4 | const { cfAccountId, cfApiToken } = useRuntimeConfig(event) 5 | console.log('useWAE', query) 6 | return $fetch(`https://api.cloudflare.com/client/v4/accounts/${cfAccountId}/analytics_engine/sql`, { 7 | method: 'POST', 8 | headers: { 9 | Authorization: `Bearer ${cfApiToken}`, 10 | }, 11 | body: query, 12 | retry: 1, 13 | retryDelay: 100, // ms 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /server/utils/query-filter.ts: -------------------------------------------------------------------------------- 1 | import type { QuerySchema } from '@@/schemas/query' 2 | import type { SelectStatement } from 'sql-bricks' 3 | import type { z } from 'zod' 4 | 5 | const { in: $in, and, eq } = SqlBricks 6 | 7 | export type Query = z.infer 8 | 9 | export function query2filter(query: Query) { 10 | const filter = [] 11 | if (query.id) 12 | filter.push(eq('index1', query.id)) 13 | 14 | Object.keys(logsMap).forEach((key) => { 15 | // @ts-expect-error todo 16 | if (query[key]) { 17 | // @ts-expect-error todo 18 | filter.push($in(logsMap[key], query[key].split(','))) 19 | } 20 | }) 21 | return filter.length ? and(...filter) : [] 22 | } 23 | 24 | export function appendTimeFilter(sql: SelectStatement, query: Query): unknown { 25 | if (query.startAt) 26 | sql.where(SqlBricks.gte('timestamp', SqlBricks(`toDateTime(${query.startAt})`))) 27 | 28 | if (query.endAt) 29 | sql.where(SqlBricks.lte('timestamp', SqlBricks(`toDateTime(${query.endAt})`))) 30 | 31 | return sql 32 | } 33 | -------------------------------------------------------------------------------- /server/utils/sql-bricks.ts: -------------------------------------------------------------------------------- 1 | import type SqlBricks from 'sql-bricks' 2 | // @ts-expect-error use SqlBricks as a type 3 | import MySqlBricks from 'mysql-bricks' 4 | 5 | const Bricks = MySqlBricks as unknown as typeof SqlBricks 6 | 7 | export { Bricks as SqlBricks } 8 | -------------------------------------------------------------------------------- /server/utils/time.ts: -------------------------------------------------------------------------------- 1 | import type { H3Event } from 'h3' 2 | 3 | export function getExpiration(event: H3Event, expiration: number | undefined) { 4 | const { previewMode } = useRuntimeConfig(event).public 5 | if (previewMode) { 6 | const { previewTTL } = useAppConfig(event) 7 | const previewExpiration = Math.floor(Date.now() / 1000) + previewTTL 8 | if (!expiration || expiration > previewExpiration) 9 | expiration = Math.floor(Date.now() / 1000) + previewTTL 10 | } 11 | 12 | return expiration 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "sink", 4 | "main": "dist/server/index.mjs", 5 | "assets": { 6 | "binding": "ASSETS", 7 | "directory": "dist/public" 8 | }, 9 | "compatibility_date": "2025-05-08", 10 | "compatibility_flags": [ 11 | "nodejs_compat" 12 | ], 13 | "keep_vars": true, 14 | "upload_source_maps": true, 15 | "ai": { 16 | "binding": "AI" 17 | }, 18 | "analytics_engine_datasets": [ 19 | { 20 | "binding": "ANALYTICS", 21 | "dataset": "sink" 22 | } 23 | ], 24 | "kv_namespaces": [ 25 | { 26 | "binding": "KV", 27 | "id": "ef93d42dc4b34969bab404d2e80f8dd3" // IMPORTANT: Change this to your KV namespace ID 28 | } 29 | ] 30 | } 31 | --------------------------------------------------------------------------------
9 | {{ $t('home.cta.description') }} 10 |
12 | 13 |
18 | 19 |