update links page to use correct qr dialog, create and edit form

pull/3/head
TZGyn 2 years ago
parent 37fb2871dc
commit 9c358a340b
Signed by: TZGyn
GPG Key ID: 122EAF77AE81FD4A

@ -1,6 +1,6 @@
// See https://kit.svelte.dev/docs/types#app // See https://kit.svelte.dev/docs/types#app
import type { Project } from '$lib/db/types' import type { Project, Setting } from '$lib/db/types'
// for information about these interfaces // for information about these interfaces
declare global { declare global {
@ -13,6 +13,56 @@ declare global {
// interface PageData {} // interface PageData {}
// interface Platform {} // interface Platform {}
interface PageState { interface PageState {
linkQR: {
user: User
breadcrumbs: {
name: string
path: string
}[]
page_title: string
shortener_url: string
shortener: {
code: string
}
settings: Setting
}
editLink: {
user: User
breadcrumbs: {
name: string
path: string
}[]
page_title: string
shortener_url: string
projects: Project[]
selectedCategory:
| {
value: string | null
label: string
}
| undefined
form: SuperValidated<
{
link: string
ios: boolean
ios_link: string
android: boolean
android_link: string
active: boolean
project?: string | undefined
},
any,
{
link: string
ios: boolean
ios_link: string
android: boolean
android_link: string
active: boolean
project?: string | undefined
}
>
}
projectLinkQR: { projectLinkQR: {
user: User user: User
breadcrumbs: { breadcrumbs: {

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button' import { Button, buttonVariants } from '$lib/components/ui/button'
import * as Dialog from '$lib/components/ui/dialog'
import * as Card from '$lib/components/ui/card' import * as Card from '$lib/components/ui/card'
import * as Tooltip from '$lib/components/ui/tooltip'
import * as DropdownMenu from '$lib/components/ui/dropdown-menu' import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
import { Badge } from '$lib/components/ui/badge' import { Badge } from '$lib/components/ui/badge'
import type { Shortener, Project, Setting } from '$lib/db/types' import type { Shortener, Project } from '$lib/db/types'
import { import {
BarChart, BarChart,
EditIcon, EditIcon,
@ -15,14 +15,16 @@
} from 'lucide-svelte' } from 'lucide-svelte'
import EditShortenerDialog from './EditShortenerDialog.svelte' import EditShortenerDialog from './EditShortenerDialog.svelte'
import DeleteShortenerDialog from './DeleteShortenerDialog.svelte' import DeleteShortenerDialog from './DeleteShortenerDialog.svelte'
import Qr from '$lib/components/QR.svelte'
import { goto, preloadData, pushState } from '$app/navigation'
import { cn } from '$lib/utils'
export let shortener: Shortener & { export let shortener: Shortener & {
projectName: string | null projectName: string | null
projectUuid: string | null
visitorCount: number visitorCount: number
} }
export let shortener_url: string export let shortener_url: string
export let settings: Setting | undefined
export let projects: Project[] export let projects: Project[]
let editDialogOpen = false let editDialogOpen = false
@ -56,12 +58,43 @@
deleteDialogOpen = true deleteDialogOpen = true
} }
let qrDialogOpen = false const getUrl = () => {
let qrCode = '' if (shortener.projectUuid) {
return `/projects/${shortener.projectUuid}`
}
return ''
}
const showEditModal = async (e: MouseEvent) => {
if (innerWidth < 640) return
const { href } = e.currentTarget as HTMLAnchorElement
const openQRDialog = (code: string) => { const result = await preloadData(href)
qrCode = code
qrDialogOpen = true if (result.type === 'loaded' && result.status === 200) {
pushState(href, { editLink: result.data })
} else {
// something bad happened! try navigating
goto(href)
}
}
const showQRModal = async (e: MouseEvent) => {
if (innerWidth < 640) return
const { href } = e.currentTarget as HTMLAnchorElement
const result = await preloadData(href)
if (result.type === 'loaded' && result.status === 200) {
if (getUrl().startsWith('/projects')) {
pushState(href, { projectLinkQR: result.data })
} else {
pushState(href, { linkQR: result.data })
}
} else {
goto(href)
}
} }
</script> </script>
@ -96,7 +129,34 @@
</Badge> </Badge>
</div> </div>
</Card.Title> </Card.Title>
<Card.Description>{shortener.link}</Card.Description> <Card.Description>
<div class="flex gap-2 items-center">
<div>
{shortener.link}
</div>
{#if shortener.ios}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="outline" class="flex gap-2">iOS</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{shortener.ios_link}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if shortener.android}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="outline" class="flex gap-2"
>Android</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{shortener.android_link}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
</Card.Description>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@ -109,11 +169,15 @@
{shortener.visitorCount} visits {shortener.visitorCount} visits
</div> </div>
</Button> </Button>
<Button <a
class="flex gap-1 justify-center items-center h-8 text-sm rounded bg-secondary" class={cn(
on:click={() => openQRDialog(shortener.code)}> buttonVariants({ variant: 'default' }),
'flex h-8 items-center justify-center gap-1 rounded bg-secondary text-sm',
)}
href={`${getUrl()}/links/${shortener.code}/qr`}
on:click|preventDefault={showQRModal}>
<QrCode size={20} /> <QrCode size={20} />
</Button> </a>
</div> </div>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
@ -121,18 +185,13 @@
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.Item <a
on:click={() => href={`/links/${shortener.code}/edit`}
openEditDialog( on:click|preventDefault={showEditModal}>
shortener.code, <DropdownMenu.Item class="flex gap-2 items-center">
shortener.link,
shortener.projectId,
shortener.projectName,
shortener.active,
)}
class="flex gap-2 items-center">
<EditIcon size={16} />Edit <EditIcon size={16} />Edit
</DropdownMenu.Item> </DropdownMenu.Item>
</a>
<DropdownMenu.Item <DropdownMenu.Item
on:click={() => openDeleteDialog(shortener.code)} on:click={() => openDeleteDialog(shortener.code)}
class="flex gap-2 items-center text-destructive data-[highlighted]:bg-destructive"> class="flex gap-2 items-center text-destructive data-[highlighted]:bg-destructive">
@ -155,24 +214,3 @@
{editShortenerCategory} /> {editShortenerCategory} />
<DeleteShortenerDialog bind:deleteDialogOpen {deleteShortenerCode} /> <DeleteShortenerDialog bind:deleteDialogOpen {deleteShortenerCode} />
<Dialog.Root bind:open={qrDialogOpen}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Shortener QR</Dialog.Title>
<Dialog.Description>
Use this QR code to share the shortener.
</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-4 items-center h-full">
<Badge variant="secondary">
{shortener_url + '/' + qrCode}
</Badge>
<Qr
bind:code={qrCode}
value={shortener_url + '/' + qrCode}
background={settings?.qr_background || '#fff'}
color={settings?.qr_foreground || '#000'} />
</div>
</Dialog.Content>
</Dialog.Root>

@ -0,0 +1,217 @@
<script lang="ts">
import * as Form from '$lib/components/ui/form'
import * as Dialog from '$lib/components/ui/dialog'
import * as Select from '$lib/components/ui/select'
import { Input } from '$lib/components/ui/input'
import { Switch } from '$lib/components/ui/switch'
import { formSchema, type FormSchema } from '../schema'
import {
type SuperValidated,
type Infer,
superForm,
} from 'sveltekit-superforms'
import { zodClient } from 'sveltekit-superforms/adapters'
import { toast } from 'svelte-sonner'
import { Loader2, LoaderCircle, PlusCircle } from 'lucide-svelte'
import { buttonVariants } from '$lib/components/ui/button'
import { Checkbox } from '$lib/components/ui/checkbox'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import type { Project } from '$lib/db/types'
export let data: SuperValidated<Infer<FormSchema>>
export let projects: Project[]
export let dialogOpen: boolean
let shortenerCategory: any = undefined
const form = superForm(data, {
validators: zodClient(formSchema),
invalidateAll: 'force',
resetForm: true,
onResult: ({ result }) => {
if (result.status === 200) {
dialogOpen = false
toast.success('Shortener added to project')
}
},
onError: ({ result }) => {
toast.error('Error adding shortener')
},
})
const { form: formData, enhance, submitting } = form
let inputTimer: any
let previewData: any
let isPreviewLoading: boolean = false
const getMetadata = async () => {
isPreviewLoading = true
clearTimeout(inputTimer)
const link =
$formData.link.startsWith('https://') ||
$formData.link.startsWith('http://')
? $formData.link
: 'https://' + $formData.link
inputTimer = setTimeout(async () => {
const response = await fetch(`/api/url/metadata?url=${link}`)
previewData = await response.json()
isPreviewLoading = false
console.log(previewData)
}, 1000)
}
</script>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Trigger
class={buttonVariants({ variant: 'default' }) + 'flex gap-2'}>
<PlusCircle />
Add Shortner
</Dialog.Trigger>
<Dialog.Content class="sm:max-w-[500px]">
<Dialog.Header>
<Dialog.Title>Add Shortener</Dialog.Title>
<Dialog.Description>
Create A New Shortener Here. Click Add To Save.
</Dialog.Description>
</Dialog.Header>
<ScrollArea class="max-h-[calc(100vh-200px)]">
<div class="grid grid-cols-4 gap-4 items-center pb-4">
<div class="font-bold">Preview</div>
<div class="flex flex-col col-span-4 justify-center border">
<div class="overflow-hidden relative h-64">
{#if isPreviewLoading}
<div class="flex justify-center items-center h-full">
<Loader2 class="animate-spin" />
</div>
{:else if previewData}
<img
src={previewData.image}
alt=""
class="object-cover w-full h-64" />
<div
class="absolute bottom-2 left-2 px-2 rounded-lg bg-secondary">
{previewData.title}
</div>
{/if}
</div>
</div>
</div>
<form
method="POST"
use:enhance
class="flex flex-col gap-6"
action="?/create">
<Form.Field {form} name="link" class="flex flex-col gap-2">
<Form.Control let:attrs>
<Form.Label>Link</Form.Label>
<Input
{...attrs}
bind:value={$formData.link}
placeholder="https://example.com"
on:input={getMetadata} />
</Form.Control>
<Form.Description>Shortener link</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="project" class="flex flex-col gap-2">
<Form.Control let:attrs>
<Form.Label>Project</Form.Label>
<Select.Root
bind:selected={shortenerCategory}
onSelectedChange={(v) => {
v && ($formData.project = v.value)
}}
multiple={false}>
<Select.Trigger {...attrs} class="col-span-3">
<Select.Value placeholder="Select a Project" />
</Select.Trigger>
<Select.Content>
<Select.Item value={''}>None</Select.Item>
<Select.Separator />
<Select.Group>
{#each projects as project}
<Select.Item value={project.uuid}>
{project.name}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
<input
hidden
bind:value={$formData.project}
name={attrs.name} />
</Form.Control>
<Form.Description>Shortener Project</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Field
{form}
name="ios"
class="flex gap-2 items-center space-y-0">
<Form.Control let:attrs>
<Form.Label>iOS Link</Form.Label>
<Switch {...attrs} bind:checked={$formData.ios} />
</Form.Control>
</Form.Field>
{#if $formData.ios}
<Form.Field
{form}
name="ios_link"
class="flex flex-col gap-2">
<Form.Control let:attrs>
<Input
{...attrs}
bind:value={$formData.ios_link}
placeholder="https://example.com" />
</Form.Control>
<Form.Description
>Shortener link for iOS</Form.Description>
<Form.FieldErrors />
</Form.Field>
{/if}
<Form.Field
{form}
name="android"
class="flex gap-2 items-center space-y-0">
<Form.Control let:attrs>
<Form.Label>Android Link</Form.Label>
<Switch {...attrs} bind:checked={$formData.android} />
</Form.Control>
</Form.Field>
{#if $formData.android}
<Form.Field
{form}
name="android_link"
class="flex flex-col gap-2">
<Form.Control let:attrs>
<Input
{...attrs}
bind:value={$formData.android_link}
placeholder="https://example.com" />
</Form.Control>
<Form.Description
>Shortener link for Android</Form.Description>
<Form.FieldErrors />
</Form.Field>
{/if}
<Form.Field
{form}
name="active"
class="flex gap-2 items-center space-y-0">
<Form.Control let:attrs>
<Checkbox {...attrs} bind:checked={$formData.active} />
<Form.Label>Active</Form.Label>
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Button class="w-fit">
{#if $submitting}
<LoaderCircle class="animate-spin" />
{/if}
Add
</Form.Button>
</form>
</ScrollArea>
</Dialog.Content>
</Dialog.Root>

@ -10,6 +10,11 @@ import {
} from 'drizzle-orm' } from 'drizzle-orm'
import type { PageServerLoad } from './$types' import type { PageServerLoad } from './$types'
import { project, shortener, visitor } from '$lib/db/schema' import { project, shortener, visitor } from '$lib/db/schema'
import { fail, setError, superValidate } from 'sveltekit-superforms'
import { zod } from 'sveltekit-superforms/adapters'
import { formSchema } from './schema'
import type { Actions } from './$types'
import { nanoid } from 'nanoid'
export const load = (async (event) => { export const load = (async (event) => {
const user = event.locals.user const user = event.locals.user
@ -64,6 +69,7 @@ export const load = (async (event) => {
.select({ .select({
...shortenerColumns, ...shortenerColumns,
projectName: project.name, projectName: project.name,
projectUuid: project.uuid,
visitorCount: sql<number>`count(${visitor.id})`, visitorCount: sql<number>`count(${visitor.id})`,
}) })
.from(shortener) .from(shortener)
@ -123,5 +129,78 @@ export const load = (async (event) => {
search, search,
sortBy, sortBy,
pagination, pagination,
form: await superValidate({ active: true }, zod(formSchema)),
} }
}) satisfies PageServerLoad }) satisfies PageServerLoad
export const actions: Actions = {
create: async (event) => {
const form = await superValidate(event, zod(formSchema))
if (!form.valid) {
return fail(400, {
form,
})
}
if (form.data.link.startsWith('http://')) {
return setError(form, 'link', 'Link must be HTTPS')
}
if (form.data.ios_link.startsWith('http://')) {
return setError(form, 'ios_link', 'Link must be HTTPS')
}
if (form.data.android_link.startsWith('http://')) {
return setError(form, 'android_link', 'Link must be HTTPS')
}
const user = event.locals.user
let project = undefined
const selected_project = form.data.project
if (selected_project) {
project = await db.query.project.findFirst({
where: (project, { eq, and }) =>
and(
eq(project.userId, user.id),
eq(project.uuid, selected_project),
),
})
}
let ios_link = ''
if (form.data.ios_link) {
if (form.data.ios_link.startsWith('https://')) {
ios_link = form.data.ios_link
} else {
ios_link = `https://${form.data.ios_link}`
}
}
let android_link = ''
if (form.data.android_link) {
if (form.data.android_link.startsWith('https://')) {
android_link = form.data.android_link
} else {
android_link = `https://${form.data.android_link}`
}
}
const code = nanoid(8)
await db.insert(shortener).values({
link: form.data.link.startsWith('https://')
? form.data.link
: `https://${form.data.link}`,
projectId: project ? project.id : undefined,
userId: user.id,
code: code,
ios: form.data.ios,
ios_link: ios_link,
android: form.data.android,
android_link: android_link,
})
return { form }
},
}

@ -3,20 +3,26 @@
import { cn } from '$lib/utils' import { cn } from '$lib/utils'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { browser } from '$app/environment' import { browser } from '$app/environment'
import { page } from '$app/stores'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import * as Select from '$lib/components/ui/select' import * as Select from '$lib/components/ui/select'
import * as Command from '$lib/components/ui/command' import * as Command from '$lib/components/ui/command'
import * as Popover from '$lib/components/ui/popover' import * as Popover from '$lib/components/ui/popover'
import * as Pagination from '$lib/components/ui/pagination' import * as Pagination from '$lib/components/ui/pagination'
import * as Dialog from '$lib/components/ui/dialog'
import { Input } from '$lib/components/ui/input' import { Input } from '$lib/components/ui/input'
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte' import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte'
import { Skeleton } from '$lib/components/ui/skeleton' import { Skeleton } from '$lib/components/ui/skeleton'
import { Check, ChevronsUpDown, SortDescIcon } from 'lucide-svelte' import { Check, ChevronsUpDown, SortDescIcon } from 'lucide-svelte'
import AddShortenerDialog from '$lib/components/AddShortenerDialog.svelte'
import ShortenerCard from '$lib/components/ShortenerCard.svelte' import ShortenerCard from '$lib/components/ShortenerCard.svelte'
import Form from './(components)/form.svelte'
import EditProjectLinkPage from '../projects/[id]/links/[linkid]/edit/+page.svelte'
import ProjectLinkQRPage from '../projects/[id]/links/[linkid]/qr/+page.svelte'
import EditLinkQRPage from './[id]/edit/+page.svelte'
import LinkQRPage from './[id]/qr/+page.svelte'
export let data: PageData export let data: PageData
@ -51,6 +57,11 @@
return '/links' return '/links'
} }
} }
$: editProjectLinkOpen = !!$page.state.editProjectLink
$: projectLinkQROpen = !!$page.state.projectLinkQR
$: editLinkOpen = !!$page.state.editLink
$: linkQROpen = !!$page.state.linkQR
</script> </script>
<div <div
@ -166,7 +177,7 @@
}} /> }} />
<Button disabled={!search} on:click={() => (search = '')} <Button disabled={!search} on:click={() => (search = '')}
>Clear</Button> >Clear</Button>
<AddShortenerDialog bind:dialogOpen projects={data.projects} /> <Form bind:dialogOpen data={data.form} projects={data.projects} />
</div> </div>
</div> </div>
@ -312,3 +323,87 @@
</Pagination.Root> </Pagination.Root>
</div> </div>
{/await} {/await}
<Dialog.Root
bind:open={editProjectLinkOpen}
onOpenChange={(open) => {
if (!open) {
history.back()
}
}}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Edit Shortener</Dialog.Title>
<Dialog.Description>
Edit Shortener Here. Click Save To Save.
</Dialog.Description>
</Dialog.Header>
<ScrollArea class="max-h-[calc(100vh-200px)]">
<EditProjectLinkPage
data={$page.state.editProjectLink}
shallowRouting />
</ScrollArea>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root
bind:open={editLinkOpen}
onOpenChange={(open) => {
if (!open) {
history.back()
}
}}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Edit Shortener</Dialog.Title>
<Dialog.Description>
Edit Shortener Here. Click Save To Save.
</Dialog.Description>
</Dialog.Header>
<ScrollArea class="max-h-[calc(100vh-200px)]">
<EditLinkQRPage data={$page.state.editLink} shallowRouting />
</ScrollArea>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root
bind:open={linkQROpen}
onOpenChange={(open) => {
if (!open) {
history.back()
}
}}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Shortener QR bla</Dialog.Title>
<Dialog.Description>
Use this QR code to share the shortener.
</Dialog.Description>
</Dialog.Header>
<ScrollArea class="max-h-[calc(100vh-200px)]">
<LinkQRPage data={$page.state.linkQR} shallowRouting />
</ScrollArea>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root
bind:open={projectLinkQROpen}
onOpenChange={(open) => {
if (!open) {
history.back()
}
}}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Shortener QR</Dialog.Title>
<Dialog.Description>
Use this QR code to share the shortener.
</Dialog.Description>
</Dialog.Header>
<ScrollArea class="max-h-[calc(100vh-200px)]">
<ProjectLinkQRPage
data={$page.state.projectLinkQR}
shallowRouting />
</ScrollArea>
</Dialog.Content>
</Dialog.Root>

@ -0,0 +1,191 @@
<script lang="ts">
import * as Form from '$lib/components/ui/form'
import * as Select from '$lib/components/ui/select'
import { Input } from '$lib/components/ui/input'
import { Switch } from '$lib/components/ui/switch'
import { formSchema, type FormSchema } from '../schema'
import {
type SuperValidated,
type Infer,
superForm,
} from 'sveltekit-superforms'
import { zodClient } from 'sveltekit-superforms/adapters'
import { toast } from 'svelte-sonner'
import { Loader2, LoaderCircle } from 'lucide-svelte'
import { Checkbox } from '$lib/components/ui/checkbox'
import { onMount } from 'svelte'
import type { Project } from '$lib/db/types'
export let data: SuperValidated<Infer<FormSchema>>
export let projects: Project[]
export let shortenerCategory: any = undefined
const form = superForm(data, {
validators: zodClient(formSchema),
invalidateAll: 'force',
resetForm: true,
onResult: ({ result }) => {
if (result.status === 200) {
toast.success('Project shortener updated')
}
},
onError: ({ result }) => {
toast.error('Error updating shortener')
},
})
const { form: formData, enhance, submitting } = form
let inputTimer: any
let previewData: any
let isPreviewLoading: boolean = false
const getMetadata = async () => {
isPreviewLoading = true
clearTimeout(inputTimer)
const link =
$formData.link.startsWith('https://') ||
$formData.link.startsWith('http://')
? $formData.link
: 'https://' + $formData.link
inputTimer = setTimeout(async () => {
const response = await fetch(`/api/url/metadata?url=${link}`)
previewData = await response.json()
isPreviewLoading = false
console.log(previewData)
}, 1000)
}
onMount(() => {
getMetadata()
})
</script>
<div class="grid grid-cols-4 gap-4 items-center pb-4">
<div class="font-bold">Preview</div>
<div class="flex flex-col col-span-4 justify-center border">
<div class="overflow-hidden relative h-64">
{#if isPreviewLoading}
<div class="flex justify-center items-center h-full">
<Loader2 class="animate-spin" />
</div>
{:else if previewData}
<img
src={previewData.image}
alt=""
class="object-cover w-full h-64" />
<div
class="absolute bottom-2 left-2 px-2 rounded-lg bg-secondary">
{previewData.title}
</div>
{/if}
</div>
</div>
</div>
<form method="POST" use:enhance class="flex flex-col gap-6">
<Form.Field {form} name="link" class="flex flex-col gap-2">
<Form.Control let:attrs>
<Form.Label>Link</Form.Label>
<Input
{...attrs}
bind:value={$formData.link}
placeholder="https://example.com"
on:input={getMetadata} />
</Form.Control>
<Form.Description>Shortener link</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="project" class="flex flex-col gap-2">
<Form.Control let:attrs>
<Form.Label>Project</Form.Label>
<Select.Root
bind:selected={shortenerCategory}
onSelectedChange={(v) => {
v && ($formData.project = v.value)
}}
multiple={false}>
<Select.Trigger {...attrs} class="col-span-3">
<Select.Value placeholder="Select a Project" />
</Select.Trigger>
<Select.Content>
<Select.Item value={''}>None</Select.Item>
<Select.Separator />
<Select.Group>
{#each projects as project}
<Select.Item value={project.uuid}>
{project.name}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
<input
hidden
bind:value={$formData.project}
name={attrs.name} />
</Form.Control>
<Form.Description>Shortener Project</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Field
{form}
name="ios"
class="flex gap-2 items-center space-y-0">
<Form.Control let:attrs>
<Form.Label>iOS Link</Form.Label>
<Switch {...attrs} bind:checked={$formData.ios} />
</Form.Control>
</Form.Field>
{#if $formData.ios}
<Form.Field {form} name="ios_link" class="flex flex-col gap-2">
<Form.Control let:attrs>
<Input
{...attrs}
bind:value={$formData.ios_link}
placeholder="https://example.com" />
</Form.Control>
<Form.Description>Shortener link for iOS</Form.Description>
<Form.FieldErrors />
</Form.Field>
{/if}
<Form.Field
{form}
name="android"
class="flex gap-2 items-center space-y-0">
<Form.Control let:attrs>
<Form.Label>Android Link</Form.Label>
<Switch {...attrs} bind:checked={$formData.android} />
</Form.Control>
</Form.Field>
{#if $formData.android}
<Form.Field
{form}
name="android_link"
class="flex flex-col gap-2">
<Form.Control let:attrs>
<Input
{...attrs}
bind:value={$formData.android_link}
placeholder="https://example.com" />
</Form.Control>
<Form.Description>Shortener link for Android</Form.Description>
<Form.FieldErrors />
</Form.Field>
{/if}
<Form.Field
{form}
name="active"
class="flex gap-2 items-center space-y-0">
<Form.Control let:attrs>
<Checkbox {...attrs} bind:checked={$formData.active} />
<Form.Label>Active</Form.Label>
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Button class="w-fit">
{#if $submitting}
<LoaderCircle class="animate-spin" />
{/if}
Save
</Form.Button>
</form>

@ -0,0 +1,139 @@
import type { PageServerLoad } from './$types'
import { fail, setError, superValidate } from 'sveltekit-superforms'
import { zod } from 'sveltekit-superforms/adapters'
import { formSchema } from './schema'
import type { Actions } from './$types'
import { db } from '$lib/db'
import { redirect } from '@sveltejs/kit'
import { shortener } from '$lib/db/schema'
import { eq } from 'drizzle-orm'
export const load = (async (event) => {
const user = event.locals.user
const { id } = event.params
const shortener = await db.query.shortener.findFirst({
columns: {
code: true,
projectId: true,
ios: true,
ios_link: true,
android: true,
android_link: true,
link: true,
active: true,
},
where: (shortener, { eq, and }) =>
and(eq(shortener.code, id), eq(shortener.userId, user.id)),
})
if (!shortener) {
redirect(300, `/links`)
}
const projects = await db.query.project.findMany({
where: (project, { eq }) => eq(project.userId, user.id),
})
let selectedCategory = undefined
if (shortener.projectId) {
const project = projects.find(
(project) => project.id === shortener.projectId,
)
if (project) {
selectedCategory = { value: project.uuid, label: project.name }
}
}
return {
projects,
selectedCategory,
form: await superValidate(
{
...shortener,
project: selectedCategory?.value || undefined,
},
zod(formSchema),
),
}
}) satisfies PageServerLoad
export const actions: Actions = {
default: async (event) => {
const form = await superValidate(event, zod(formSchema))
if (!form.valid) {
return fail(400, {
form,
})
}
if (form.data.link.startsWith('http://')) {
return setError(form, 'link', 'Link must be HTTPS')
}
if (
form.data.ios_link &&
form.data.ios_link.startsWith('http://')
) {
return setError(form, 'ios_link', 'Link must be HTTPS')
}
if (
form.data.android_link &&
form.data.android_link.startsWith('http://')
) {
return setError(form, 'android_link', 'Link must be HTTPS')
}
const user = event.locals.user
let project = undefined
const selected_project = form.data.project
if (selected_project) {
project = await db.query.project.findFirst({
where: (project, { eq, and }) =>
and(
eq(project.userId, user.id),
eq(project.uuid, selected_project),
),
})
}
let ios_link = ''
if (form.data.ios_link) {
if (form.data.ios_link.startsWith('https://')) {
ios_link = form.data.ios_link
} else {
ios_link = `https://${form.data.ios_link}`
}
}
let android_link = ''
if (form.data.android_link) {
if (form.data.android_link.startsWith('https://')) {
android_link = form.data.android_link
} else {
android_link = `https://${form.data.android_link}`
}
}
await db
.update(shortener)
.set({
link: form.data.link.startsWith('https://')
? form.data.link
: `https://${form.data.link}`,
projectId: project ? project.id : null,
userId: user.id,
ios: form.data.ios,
ios_link: ios_link,
android: form.data.android,
android_link: android_link,
})
.where(eq(shortener.code, event.params.id))
return { form }
},
}

@ -0,0 +1,24 @@
<script lang="ts">
import { ScrollArea } from '$lib/components/ui/scroll-area'
import type { PageData } from './$types'
import Form from './(components)/form.svelte'
export let data: PageData
export let shallowRouting = false
</script>
{#if !shallowRouting}
<ScrollArea>
<div class="py-4 px-10 max-w-2xl">
<Form
data={data.form}
projects={data.projects}
shortenerCategory={data.selectedCategory} />
</div>
</ScrollArea>
{:else}
<Form
data={data.form}
projects={data.projects}
shortenerCategory={data.selectedCategory} />
{/if}

@ -0,0 +1,13 @@
import { z } from 'zod'
export const formSchema = z.object({
link: z.string(),
project: z.string().optional(),
active: z.boolean(),
ios: z.boolean(),
ios_link: z.string().nullable(),
android: z.boolean(),
android_link: z.string().nullable(),
})
export type FormSchema = typeof formSchema

@ -0,0 +1,85 @@
<script lang="ts">
import QRCode from 'qrcode'
import { Button } from '$lib/components/ui/button'
import { toast } from 'svelte-sonner'
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
import { Badge } from '$lib/components/ui/badge'
export let background = '#fff'
export let color = '#000'
export let value = ''
export let code = ''
let image = ''
const copyImageToClipboard = async () => {
if (!image) return
const imageData = await fetch(image)
const imageBlob = await imageData.blob()
try {
navigator.clipboard.write([
new ClipboardItem({
'image/png': imageBlob,
}),
])
toast.success('Copied Image To Clipboard')
} catch (error) {
toast.error(
'Unable to copy item to clipboard. If you are using firefox, you can change the setting dom.events.asyncclipboard.clipboarditem in about:config to true',
)
}
}
async function generateQrCode() {
try {
image = await QRCode.toDataURL(value, {
errorCorrectionLevel: 'L',
margin: 1,
scale: 20,
color: {
light: background,
dark: color,
},
})
} catch (e) {
image = await QRCode.toDataURL(value, {
errorCorrectionLevel: 'L',
margin: 1,
scale: 20,
})
}
}
$: {
if (value) {
generateQrCode()
}
}
</script>
<div class="flex flex-col gap-4 items-center h-full">
<Badge variant="secondary">
{value}
</Badge>
<img src={image} alt={value} width={300} height={300} />
<div class="flex gap-4 w-full">
<Button class="w-full" on:click={copyImageToClipboard}
>Copy Image</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button builders={[builder]} class="w-full">QR Link</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item
href={`/api/shortener/${code}/qr`}
target="_blank">Standard</DropdownMenu.Item>
<DropdownMenu.Item
href={`/api/shortener/${code}/qr?color=true`}
target="_blank">With Color</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>

@ -0,0 +1,26 @@
import { db } from '$lib/db'
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
export const load = (async (event) => {
const user = event.locals.user
const { id } = event.params
const shortener = await db.query.shortener.findFirst({
columns: {
code: true,
},
where: (shortener, { eq, and, isNull }) =>
and(eq(shortener.code, id), isNull(shortener.projectId)),
})
if (!shortener) {
redirect(300, `/links`)
}
const settings = await db.query.setting.findFirst({
where: (settings, { eq }) => eq(settings.userId, user.id),
})
return { shortener, settings }
}) satisfies PageServerLoad

@ -0,0 +1,26 @@
<script lang="ts">
import { ScrollArea } from '$lib/components/ui/scroll-area'
import type { PageData } from './$types'
import QR from './(components)/qr.svelte'
export let data: PageData
export let shallowRouting = false
</script>
{#if !shallowRouting}
<ScrollArea>
<div class="py-4 px-10 max-w-2xl">
<QR
value={data.shortener_url + '/' + data.shortener.code}
code={data.shortener.code}
background={data.settings?.qr_background || '#fff'}
color={data.settings?.qr_foreground || '#000'} />
</div>
</ScrollArea>
{:else}
<QR
value={data.shortener_url + '/' + data.shortener.code}
code={data.shortener.code}
background={data.settings?.qr_background || '#fff'}
color={data.settings?.qr_foreground || '#000'} />
{/if}

@ -0,0 +1,13 @@
import { z } from 'zod'
export const formSchema = z.object({
link: z.string(),
project: z.string().optional(),
active: z.boolean(),
ios: z.boolean(),
ios_link: z.string(),
android: z.boolean(),
android_link: z.string(),
})
export type FormSchema = typeof formSchema
Loading…
Cancel
Save