mirror of
https://github.com/Nezumi-2711/Sink.git
synced 2026-09-22 13:48:35 +00:00
feat: init
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:link'])
|
||||
|
||||
async function deleteLink() {
|
||||
await useAPI('/api/link/delete', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
slug: props.link.slug,
|
||||
},
|
||||
})
|
||||
emit('update:link', props.link, 'delete')
|
||||
toast('Delete successful!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger as-child>
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent class="max-w-[95svw] max-h-[95svh] md:max-w-lg grid-rows-[auto_minmax(0,1fr)_auto]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will really delete your link from servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="deleteLink">
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup>
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { Shuffle, Sparkles } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { DependencyType } from '@/components/ui/auto-form/interface'
|
||||
import { LinkSchema, nanoid } from '@/schemas/link'
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:link'])
|
||||
|
||||
const link = ref(props.link)
|
||||
const dialogOpen = ref(false)
|
||||
|
||||
const isEdit = !!props.link.id
|
||||
|
||||
const EditLinkSchema = LinkSchema.pick({
|
||||
url: true,
|
||||
slug: true,
|
||||
}).extend({
|
||||
optional: LinkSchema.omit({
|
||||
id: true,
|
||||
url: true,
|
||||
slug: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
title: true,
|
||||
description: true,
|
||||
image: true,
|
||||
}).extend({
|
||||
expiration: z.coerce.date().optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
const fieldConfig = {
|
||||
slug: {
|
||||
disabled: isEdit,
|
||||
},
|
||||
optional: {
|
||||
comment: {
|
||||
component: 'textarea',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const dependencies = [
|
||||
{
|
||||
sourceField: 'slug',
|
||||
type: DependencyType.DISABLES,
|
||||
targetField: 'slug',
|
||||
when: () => isEdit,
|
||||
},
|
||||
]
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(EditLinkSchema),
|
||||
initialValues: {
|
||||
slug: link.value.slug,
|
||||
url: link.value.url,
|
||||
optional: {
|
||||
comment: link.value.comment,
|
||||
},
|
||||
},
|
||||
validateOnMount: isEdit,
|
||||
keepValuesOnUnmount: isEdit,
|
||||
})
|
||||
|
||||
function randomSlug() {
|
||||
form.setFieldValue('slug', nanoid()())
|
||||
}
|
||||
|
||||
const aiSlugPending = ref(false)
|
||||
async function aiSlug() {
|
||||
if (!form.values.url)
|
||||
return
|
||||
|
||||
aiSlugPending.value = true
|
||||
try {
|
||||
const { slug } = await useAPI('/api/link/ai', {
|
||||
query: {
|
||||
url: form.values.url,
|
||||
},
|
||||
})
|
||||
form.setFieldValue('slug', slug)
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
aiSlugPending.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (link.value.expiration) {
|
||||
form.setFieldValue('optional.expiration', unix2date(link.value.expiration))
|
||||
}
|
||||
})
|
||||
|
||||
async function onSubmit(formData) {
|
||||
const link = {
|
||||
url: formData.url,
|
||||
slug: formData.slug,
|
||||
...(formData.optional || []),
|
||||
expiration: formData.optional?.expiration ? date2unix(formData.optional?.expiration, 'end') : undefined,
|
||||
}
|
||||
const { link: newLink } = await useAPI(isEdit ? '/api/link/edit' : '/api/link/create', {
|
||||
method: isEdit ? 'PUT' : 'POST',
|
||||
body: link,
|
||||
})
|
||||
dialogOpen.value = false
|
||||
emit('update:link', newLink, isEdit ? 'edit' : 'create')
|
||||
isEdit ? toast('Link updated successfully') : toast('Link created successfully')
|
||||
}
|
||||
|
||||
const { previewMode } = useRuntimeConfig().public
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<slot>
|
||||
<Button
|
||||
class="ml-2"
|
||||
variant="outline"
|
||||
@click="randomSlug"
|
||||
>
|
||||
Create Link
|
||||
</Button>
|
||||
</slot>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="max-w-[95svw] max-h-[95svh] md:max-w-lg grid-rows-[auto_minmax(0,1fr)_auto]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ link.id ? 'Edit Link' : 'Create Link' }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p
|
||||
v-if="previewMode"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
The preview mode link is valid for up to 24 hours.
|
||||
</p>
|
||||
<AutoForm
|
||||
class="px-2 space-y-2 overflow-y-auto"
|
||||
:schema="EditLinkSchema"
|
||||
:form="form"
|
||||
:field-config="fieldConfig"
|
||||
:dependencies="dependencies"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<template #slug="slotProps">
|
||||
<div
|
||||
v-if="!isEdit"
|
||||
class="relative"
|
||||
>
|
||||
<div class="absolute right-0 flex space-x-3 top-1">
|
||||
<Shuffle
|
||||
class="w-4 h-4 cursor-pointer"
|
||||
@click="randomSlug"
|
||||
/>
|
||||
<Sparkles
|
||||
class="w-4 h-4 cursor-pointer"
|
||||
:class="{ 'animate-bounce': aiSlugPending }"
|
||||
@click="aiSlug"
|
||||
/>
|
||||
</div>
|
||||
<AutoFormField
|
||||
v-bind="slotProps"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<DialogFooter>
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="mt-2 sm:mt-0"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</AutoForm>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { Loader } from 'lucide-vue-next'
|
||||
import { useInfiniteScroll } from '@vueuse/core'
|
||||
|
||||
const links = ref([])
|
||||
const limit = 24
|
||||
let cursor = ''
|
||||
let listComplete = false
|
||||
|
||||
async function getLinks() {
|
||||
const data = await useAPI('/api/link/list', {
|
||||
query: {
|
||||
limit,
|
||||
cursor,
|
||||
},
|
||||
})
|
||||
links.value = links.value.concat(data.links)
|
||||
cursor = data.cursor
|
||||
listComplete = data.list_complete
|
||||
}
|
||||
|
||||
const { isLoading } = useInfiniteScroll(
|
||||
document,
|
||||
getLinks,
|
||||
{ distance: 10, interval: 1000, canLoadMore: () => !listComplete },
|
||||
)
|
||||
|
||||
function updateLinkList(link, type) {
|
||||
if (type === 'edit') {
|
||||
const index = links.value.findIndex(l => l.id === link.id)
|
||||
links.value[index] = link
|
||||
}
|
||||
else if (type === 'delete') {
|
||||
const index = links.value.findIndex(l => l.id === link.id)
|
||||
links.value.splice(index, 1)
|
||||
}
|
||||
else {
|
||||
links.value.unshift(link)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="space-y-6">
|
||||
<DashboardNav>
|
||||
<DashboardLinksEditor @update:link="updateLinkList" />
|
||||
</DashboardNav>
|
||||
<section class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<DashboardLinksLink
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
:link="link"
|
||||
@update:link="updateLinkList"
|
||||
/>
|
||||
</section>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
<Loader class="animate-spin" />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup>
|
||||
import { CalendarPlus2, Copy, CopyCheck, Eraser, Hourglass, Link as LinkIcon, QrCode, SquareChevronDown, SquarePen } from 'lucide-vue-next'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { parseURL } from 'ufo'
|
||||
import QRCode from './QRCode.vue'
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:link'])
|
||||
|
||||
const editPopoverOpen = ref(false)
|
||||
|
||||
const { host, origin } = location
|
||||
|
||||
function getLinkHost(url) {
|
||||
const { host } = parseURL(url)
|
||||
return host
|
||||
}
|
||||
|
||||
const shortLink = computed(() => `${origin}/${props.link.slug}`)
|
||||
const linkIcon = computed(() => `https://unavatar.io/${getLinkHost(props.link.url)}?fallback=https://sink.cool/sink.png`)
|
||||
|
||||
const { copy, copied } = useClipboard({ source: shortLink.value, copiedDuring: 400 })
|
||||
|
||||
function updateLink(link, type) {
|
||||
emit('update:link', link, type)
|
||||
editPopoverOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<NuxtLink
|
||||
class="flex flex-col p-4 space-y-3"
|
||||
:to="`/dashboard/link?slug=${link.slug}`"
|
||||
>
|
||||
<div class="flex items-center justify-center space-x-3">
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
:src="linkIcon"
|
||||
alt="@radix-vue"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<img
|
||||
src="/sink.png"
|
||||
alt="Sink"
|
||||
>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="flex items-center">
|
||||
<div class="font-bold leading-5 truncate text-md">
|
||||
{{ host }}/{{ link.slug }}
|
||||
</div>
|
||||
|
||||
<CopyCheck
|
||||
v-if="copied"
|
||||
class="w-4 h-4 ml-1 shrink-0"
|
||||
@click.prevent
|
||||
/>
|
||||
<Copy
|
||||
v-else
|
||||
class="w-4 h-4 ml-1 shrink-0"
|
||||
@click.prevent="copy(shortLink);toast('Copy successful!')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<p class="text-sm truncate">
|
||||
{{ link.comment || link.title || link.description }}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p class="max-w-[90svw] break-all">
|
||||
{{ link.comment || link.title || link.description }}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click.stop
|
||||
>
|
||||
<LinkIcon class="w-5 h-5" />
|
||||
</a>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<QrCode
|
||||
class="w-5 h-5"
|
||||
@click.prevent
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<QRCode
|
||||
:data="shortLink"
|
||||
:image="linkIcon"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover v-model:open="editPopoverOpen">
|
||||
<PopoverTrigger>
|
||||
<SquareChevronDown
|
||||
class="w-5 h-5"
|
||||
@click.prevent
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
class="w-auto p-0"
|
||||
:hide-when-detached="false"
|
||||
>
|
||||
<DashboardLinksEditor
|
||||
:link="link"
|
||||
@update:link="updateLink"
|
||||
>
|
||||
<div
|
||||
class="cursor-pointer flex select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<SquarePen
|
||||
class="w-5 h-5 mr-2"
|
||||
/>
|
||||
Edit
|
||||
</div>
|
||||
</DashboardLinksEditor>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DashboardLinksDelete
|
||||
:link="link"
|
||||
@update:link="updateLink"
|
||||
>
|
||||
<div
|
||||
class="cursor-pointer flex select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Eraser
|
||||
class="w-5 h-5 mr-2"
|
||||
/> Delete
|
||||
</div>
|
||||
</DashboardLinksDelete>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div class="flex w-full h-5 space-x-2 text-sm">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="inline-flex items-center leading-5"><CalendarPlus2 class="w-4 h-4 mr-1" /> {{ shortDate(link.createdAt) }}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Created At: {{ longDate(link.createdAt) }}</p>
|
||||
<p>Updated At: {{ longDate(link.updatedAt) }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<template v-if="link.expiration">
|
||||
<Separator orientation="vertical" />
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="inline-flex items-center leading-5"><Hourglass class="w-4 h-4 mr-1" /> {{ shortDate(link.expiration) }}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Expires At: {{ longDate(link.expiration) }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
<Separator orientation="vertical" />
|
||||
<span class="truncate">{{ link.url }}</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import QRCodeStyling from 'qr-code-styling'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
const options = {
|
||||
width: 256,
|
||||
height: 256,
|
||||
data: props.data,
|
||||
margin: 10,
|
||||
qrOptions: { typeNumber: '0', mode: 'Byte', errorCorrectionLevel: 'Q' },
|
||||
imageOptions: { hideBackgroundDots: true, imageSize: 0.4, margin: 2 },
|
||||
dotsOptions: { type: 'dots', color: '#000000', gradient: null },
|
||||
backgroundOptions: { color: '#ffffff', gradient: null },
|
||||
image: props.image,
|
||||
dotsOptionsHelper: {
|
||||
colorType: { single: true, gradient: false },
|
||||
gradient: {
|
||||
linear: true,
|
||||
radial: false,
|
||||
color1: '#6a1a4c',
|
||||
color2: '#6a1a4c',
|
||||
rotation: '0',
|
||||
},
|
||||
},
|
||||
cornersSquareOptions: { type: 'extra-rounded', color: '#000000' },
|
||||
cornersSquareOptionsHelper: {
|
||||
colorType: { single: true, gradient: false },
|
||||
gradient: {
|
||||
linear: true,
|
||||
radial: false,
|
||||
color1: '#000000',
|
||||
color2: '#000000',
|
||||
rotation: '0',
|
||||
},
|
||||
},
|
||||
cornersDotOptions: { type: 'dot', color: '#000000' },
|
||||
cornersDotOptionsHelper: {
|
||||
colorType: { single: true, gradient: false },
|
||||
gradient: {
|
||||
linear: true,
|
||||
radial: false,
|
||||
color1: '#000000',
|
||||
color2: '#000000',
|
||||
rotation: '0',
|
||||
},
|
||||
},
|
||||
backgroundOptionsHelper: {
|
||||
colorType: { single: true, gradient: false },
|
||||
gradient: {
|
||||
linear: true,
|
||||
radial: false,
|
||||
color1: '#ffffff',
|
||||
color2: '#ffffff',
|
||||
rotation: '0',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const qrCode = new QRCodeStyling(options)
|
||||
const qrCodeEl = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
qrCode.append(qrCodeEl.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="qrCodeEl"
|
||||
:data-text="data"
|
||||
/>
|
||||
</template>
|
||||
Reference in New Issue
Block a user