mirror of https://github.com/TZGyn/shortener
file upload (currently tigris) + migrate old dashboard to new one
parent
968e1568e2
commit
7eae6141c3
Binary file not shown.
@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS "file" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"project_id" text,
|
||||
"key" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"size" bigint DEFAULT 0 NOT NULL,
|
||||
"etag" text NOT NULL,
|
||||
"created_at_epoch" bigint NOT NULL,
|
||||
"updated_at_epoch" bigint NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "shortener" ADD COLUMN "is_file_upload" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "shortener" ADD COLUMN "file_path" text;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "file_storage_usage_in_byte" bigint DEFAULT 0 NOT NULL;
|
||||
@ -0,0 +1,542 @@
|
||||
{
|
||||
"id": "ce042384-c48f-4a7b-b067-8808e73834d6",
|
||||
"prevId": "c91acbf2-ca0a-4726-b2b2-70fd1f8bd9ff",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.email_verification_token": {
|
||||
"name": "email_verification_token",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.file": {
|
||||
"name": "file",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"etag": {
|
||||
"name": "etag",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at_epoch": {
|
||||
"name": "created_at_epoch",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at_epoch": {
|
||||
"name": "updated_at_epoch",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.project": {
|
||||
"name": "project",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"uuid": {
|
||||
"name": "uuid",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"qr_background": {
|
||||
"name": "qr_background",
|
||||
"type": "varchar(7)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'#ffffff'"
|
||||
},
|
||||
"qr_foreground": {
|
||||
"name": "qr_foreground",
|
||||
"type": "varchar(7)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'#000000'"
|
||||
},
|
||||
"domain_status": {
|
||||
"name": "domain_status",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'verified'"
|
||||
},
|
||||
"enable_custom_domain": {
|
||||
"name": "enable_custom_domain",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"custom_ip": {
|
||||
"name": "custom_ip",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"custom_domain_id": {
|
||||
"name": "custom_domain_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"custom_domain": {
|
||||
"name": "custom_domain",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"qr_corner_square_style": {
|
||||
"name": "qr_corner_square_style",
|
||||
"type": "varchar",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'square'"
|
||||
},
|
||||
"qr_dot_style": {
|
||||
"name": "qr_dot_style",
|
||||
"type": "varchar",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'square'"
|
||||
},
|
||||
"qr_image_base64": {
|
||||
"name": "qr_image_base64",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.shortener": {
|
||||
"name": "shortener",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"link": {
|
||||
"name": "link",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ios": {
|
||||
"name": "ios",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"ios_link": {
|
||||
"name": "ios_link",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"android": {
|
||||
"name": "android",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"android_link": {
|
||||
"name": "android_link",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"code": {
|
||||
"name": "code",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"active": {
|
||||
"name": "active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_file_upload": {
|
||||
"name": "is_file_upload",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"file_path": {
|
||||
"name": "file_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"uuid": {
|
||||
"name": "uuid",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"google_id": {
|
||||
"name": "google_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"plan": {
|
||||
"name": "plan",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'free'"
|
||||
},
|
||||
"stripe_customer_id": {
|
||||
"name": "stripe_customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"qr_background": {
|
||||
"name": "qr_background",
|
||||
"type": "varchar(7)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'#fff'"
|
||||
},
|
||||
"qr_foreground": {
|
||||
"name": "qr_foreground",
|
||||
"type": "varchar(7)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'#000'"
|
||||
},
|
||||
"qr_corner_square_style": {
|
||||
"name": "qr_corner_square_style",
|
||||
"type": "varchar",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'square'"
|
||||
},
|
||||
"qr_dot_style": {
|
||||
"name": "qr_dot_style",
|
||||
"type": "varchar",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'square'"
|
||||
},
|
||||
"qr_image_base64": {
|
||||
"name": "qr_image_base64",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"file_storage_usage_in_byte": {
|
||||
"name": "file_storage_usage_in_byte",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"public.visitor": {
|
||||
"name": "visitor",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"shortener_id": {
|
||||
"name": "shortener_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"country_code": {
|
||||
"name": "country_code",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"city": {
|
||||
"name": "city",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"device_type": {
|
||||
"name": "device_type",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"device_vendor": {
|
||||
"name": "device_vendor",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"os": {
|
||||
"name": "os",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"browser": {
|
||||
"name": "browser",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"referer": {
|
||||
"name": "referer",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@ -1,22 +1,35 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { HTMLInputAttributes } from 'svelte/elements'
|
||||
import type { WithElementRef } from 'bits-ui'
|
||||
import { cn } from '$lib/utils'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
files = $bindable(),
|
||||
class: className,
|
||||
type,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLInputAttributes> = $props();
|
||||
}: WithElementRef<
|
||||
HTMLInputAttributes & { files?: FileList }
|
||||
> = $props()
|
||||
const defaultClass =
|
||||
'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{#if type === 'file'}
|
||||
<input
|
||||
bind:this={ref}
|
||||
type="file"
|
||||
class={cn(defaultClass, className)}
|
||||
bind:value
|
||||
bind:files
|
||||
{...restProps} />
|
||||
{:else}
|
||||
<input
|
||||
bind:this={ref}
|
||||
{type}
|
||||
class={cn(defaultClass, className)}
|
||||
bind:value
|
||||
{...restProps} />
|
||||
{/if}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
import type { LayoutServerLoad } from './$types'
|
||||
|
||||
export const load = (async (event) => {
|
||||
const user = event.locals.user
|
||||
return { user }
|
||||
}) satisfies LayoutServerLoad
|
||||
@ -1,14 +0,0 @@
|
||||
import { redirect } from '@sveltejs/kit'
|
||||
import type { PageServerLoad, Actions } from './$types'
|
||||
|
||||
export const load = (async (event) => {
|
||||
redirect(302, '/dashboard-new/project/personal')
|
||||
}) satisfies PageServerLoad
|
||||
|
||||
export const actions = {
|
||||
signout: async (event) => {
|
||||
console.log('signout')
|
||||
event.cookies.delete('token', { path: '/' })
|
||||
redirect(303, '/login')
|
||||
},
|
||||
} satisfies Actions
|
||||
@ -1,6 +0,0 @@
|
||||
import { redirect } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load = (async () => {
|
||||
redirect(300, '/dashboard-new/account/settings/account')
|
||||
}) satisfies PageServerLoad
|
||||
@ -1,23 +1,6 @@
|
||||
import { env } from '$env/dynamic/public'
|
||||
import { db } from '$lib/db'
|
||||
import type { LayoutServerLoad } from './$types'
|
||||
|
||||
export const load = (async (event) => {
|
||||
const user = event.locals.user
|
||||
|
||||
const projects = await db.query.project.findMany({
|
||||
where: (project, { eq }) => eq(project.userId, user.id),
|
||||
})
|
||||
|
||||
const breadcrumbs = [{ name: 'Home', path: '/dashboard' }]
|
||||
|
||||
const page_title = 'Home'
|
||||
|
||||
return {
|
||||
shortener_url: env.PUBLIC_SHORTENER_URL,
|
||||
user: user,
|
||||
breadcrumbs,
|
||||
page_title,
|
||||
projects,
|
||||
}
|
||||
return { user }
|
||||
}) satisfies LayoutServerLoad
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import ThemeToggle from '$lib/components/theme-toggle.svelte'
|
||||
import * as Avatar from '$lib/components/ui/avatar'
|
||||
import * as Breadcrumb from '$lib/components/ui/breadcrumb'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
|
||||
import * as Select from '$lib/components/ui/select/index.js'
|
||||
import {
|
||||
Blocks,
|
||||
CreditCardIcon,
|
||||
Home,
|
||||
Link,
|
||||
Settings,
|
||||
UserIcon,
|
||||
} from 'lucide-svelte'
|
||||
import AppSidebar from '$lib/components/app-sidebar.svelte'
|
||||
import { Separator } from '$lib/components/ui/separator/index.js'
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js'
|
||||
|
||||
import { goto } from '$app/navigation'
|
||||
import { Loader2, User } from 'lucide-svelte'
|
||||
import { toast } from 'svelte-sonner'
|
||||
|
||||
let dialogOpen = $state(false)
|
||||
let isLoading = $state(false)
|
||||
|
||||
const logout = async () => {
|
||||
isLoading = true
|
||||
|
||||
await fetch('/api/logout', { method: 'post' })
|
||||
|
||||
isLoading = false
|
||||
dialogOpen = false
|
||||
toast.success('Logged Out Successfully')
|
||||
goto('/login')
|
||||
}
|
||||
|
||||
let { data, children } = $props()
|
||||
</script>
|
||||
|
||||
<Sidebar.Provider>
|
||||
<AppSidebar user={data.user} />
|
||||
<Sidebar.Inset>
|
||||
<header
|
||||
class="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<Sidebar.Trigger class="-ml-1" />
|
||||
<Separator orientation="vertical" class="mr-2 h-4" />
|
||||
<Breadcrumb.Root>
|
||||
<Breadcrumb.List>
|
||||
{#if $page.data.breadcrumbs}
|
||||
{#each $page.data.breadcrumbs as breadcrumb, index}
|
||||
{#if index == $page.data.breadcrumbs.length - 1}
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Page>
|
||||
{breadcrumb.name}
|
||||
</Breadcrumb.Page>
|
||||
</Breadcrumb.Item>
|
||||
{:else}
|
||||
<Breadcrumb.Item class="hidden md:block">
|
||||
<Breadcrumb.Link href={breadcrumb.path}>
|
||||
{breadcrumb.name}
|
||||
</Breadcrumb.Link>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Separator class="hidden md:block" />
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Link href={'/dashboard'}>
|
||||
Home
|
||||
</Breadcrumb.Link>
|
||||
</Breadcrumb.Item>
|
||||
{/if}
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="flex max-h-[calc(100vh-64px)] flex-grow overflow-hidden">
|
||||
<div class="flex h-auto w-full flex-col">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header class="space-y-12">
|
||||
<div
|
||||
class="flex w-full flex-col items-center justify-center gap-6 pt-12">
|
||||
<div class="bg-destructive/30 w-fit rounded-full p-4">
|
||||
<UserIcon class="text-destructive" size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Dialog.Title class="text-center">Log Out?</Dialog.Title>
|
||||
<Dialog.Description class="text-center">
|
||||
You are about to log out of this account.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<div class="flex justify-center gap-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
onclick={() => {
|
||||
dialogOpen = false
|
||||
}}
|
||||
disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={logout}
|
||||
class="flex w-full gap-2"
|
||||
disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<Loader2 class="animate-spin" />
|
||||
{/if}
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Header>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import { BarChart, ExternalLink } from 'lucide-svelte'
|
||||
|
||||
let { data } = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 px-10 py-8">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Projects</h2>
|
||||
<Separator class="" />
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-wrap items-stretch gap-2 px-6">
|
||||
{#each data.projects as project}
|
||||
<a href={'/dashboard/projects/' + project.uuid}>
|
||||
<Card.Root
|
||||
class="hover:bg-secondary w-[500px] hover:cursor-pointer">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
{project.name}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="flex w-full gap-4">
|
||||
<Button
|
||||
class="bg-secondary flex h-8 items-center justify-center gap-1 rounded text-sm">
|
||||
<ExternalLink size={20} />
|
||||
{project.shortener.length}
|
||||
Shorteners
|
||||
</Button>
|
||||
<Button
|
||||
class="bg-secondary flex h-8 items-center justify-center gap-1 rounded text-sm">
|
||||
<BarChart size={20} />
|
||||
{project.shortener.reduce(
|
||||
(curr, shortener) => shortener.visitor.length + curr,
|
||||
0,
|
||||
)}
|
||||
visits
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
@ -1,269 +0,0 @@
|
||||
<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'
|
||||
|
||||
let {
|
||||
data,
|
||||
projects,
|
||||
dialogOpen = $bindable(),
|
||||
}: {
|
||||
data: SuperValidated<Infer<FormSchema>>
|
||||
projects: Project[]
|
||||
dialogOpen: boolean
|
||||
} = $props()
|
||||
let shortenerCategory = $state<string>('')
|
||||
let selectedProjectName = $derived(
|
||||
projects.find((project) => project.id == shortenerCategory)
|
||||
?.name || 'None',
|
||||
)
|
||||
|
||||
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 = $state<any>()
|
||||
let previewData = $state<any>()
|
||||
let isPreviewLoading = $state(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 items-center gap-4 pb-4">
|
||||
<div class="font-bold">Preview</div>
|
||||
<div class="col-span-4 flex flex-col justify-center border">
|
||||
<div class="relative h-64 overflow-hidden">
|
||||
{#if isPreviewLoading}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
{:else if previewData}
|
||||
<img
|
||||
src={previewData.image}
|
||||
alt=""
|
||||
class="h-64 w-full object-cover" />
|
||||
<div
|
||||
class="bg-secondary absolute bottom-2 left-2 rounded-lg px-2">
|
||||
{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>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Link</Form.Label>
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.link}
|
||||
placeholder="https://example.com"
|
||||
oninput={getMetadata} />
|
||||
{/snippet}
|
||||
</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>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Project</Form.Label>
|
||||
<Select.Root
|
||||
bind:value={shortenerCategory}
|
||||
type={'single'}
|
||||
name={props.name}>
|
||||
<Select.Trigger {...props} class="col-span-3">
|
||||
{selectedProjectName}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Item value={''}>None</Select.Item>
|
||||
<Select.Separator />
|
||||
{#each projects as project}
|
||||
<Select.Item value={project.id}>
|
||||
{project.name}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>Shortener Project</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field
|
||||
{form}
|
||||
name="custom_code_enable"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Switch
|
||||
{...props}
|
||||
bind:checked={$formData.custom_code_enable} />
|
||||
<Form.Label>Custom Code</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
{#if $formData.custom_code_enable}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="custom_code"
|
||||
class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.custom_code}
|
||||
placeholder="abcde" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
Custom Code For The Shortener
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="ios"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Switch {...props} bind:checked={$formData.ios} />
|
||||
<Form.Label>iOS Link</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
{#if $formData.ios}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="ios_link"
|
||||
class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.ios_link}
|
||||
placeholder="https://example.com" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
Shortener link for iOS
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="android"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Switch {...props} bind:checked={$formData.android} />
|
||||
<Form.Label>Android Link</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
{#if $formData.android}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="android_link"
|
||||
class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.android_link}
|
||||
placeholder="https://example.com" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
Shortener link for Android
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="active"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Checkbox {...props} bind:checked={$formData.active} />
|
||||
<Form.Label>Active</Form.Label>
|
||||
{/snippet}
|
||||
</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>
|
||||
@ -1,14 +0,0 @@
|
||||
import type { LayoutServerLoad } from './$types'
|
||||
|
||||
export const load = (async (event) => {
|
||||
const { breadcrumbs: parentBreadcrumbs } = await event.parent()
|
||||
|
||||
const breadcrumbs = [
|
||||
...parentBreadcrumbs,
|
||||
{ name: 'Links', path: '/dashboard/links' },
|
||||
]
|
||||
|
||||
const page_title = 'Links'
|
||||
|
||||
return { breadcrumbs, page_title }
|
||||
}) satisfies LayoutServerLoad
|
||||
@ -1,139 +0,0 @@
|
||||
import { db } from '$lib/db'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { shortener } 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'
|
||||
import { isAlphanumeric } from '$lib/utils'
|
||||
import { generateId } from 'lucia'
|
||||
import type { Project } from '$lib/db/types'
|
||||
|
||||
export const load = (async (event) => {
|
||||
const user = event.locals.user
|
||||
|
||||
const projects = db.query.project.findMany({
|
||||
where: (project, { eq }) => eq(project.userId, user.id),
|
||||
})
|
||||
|
||||
return {
|
||||
projects,
|
||||
form: await superValidate({ active: true }, zod(formSchema), {
|
||||
errors: false,
|
||||
}),
|
||||
}
|
||||
}) satisfies PageServerLoad
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async (event) => {
|
||||
const form = await superValidate(event, zod(formSchema))
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form,
|
||||
})
|
||||
}
|
||||
|
||||
const user = event.locals.user
|
||||
let project: Project | undefined = 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),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (form.data.custom_code_enable) {
|
||||
if (!form.data.custom_code) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Please Enter Custom Code',
|
||||
)
|
||||
}
|
||||
if (!isAlphanumeric(form.data.custom_code)) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Code cannot contain special characters',
|
||||
)
|
||||
}
|
||||
|
||||
const customCodeExist = await db.query.shortener.findMany({
|
||||
where: (shortener, { eq, and, ne }) =>
|
||||
and(eq(shortener.code, form.data.custom_code)),
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
})
|
||||
|
||||
for (const shortener of customCodeExist) {
|
||||
if (!shortener.project) {
|
||||
if (
|
||||
!project ||
|
||||
(project && !project.enable_custom_domain)
|
||||
) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Duplicated Custom Code',
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (project) {
|
||||
if (
|
||||
!shortener.project.enable_custom_domain &&
|
||||
!project.enable_custom_domain
|
||||
) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Duplicated Custom Code',
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
shortener.project.custom_domain ===
|
||||
project.custom_domain
|
||||
) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Duplicated Custom Code',
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (!shortener.project.enable_custom_domain) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Duplicated Custom Code',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const code = form.data.custom_code_enable
|
||||
? form.data.custom_code
|
||||
: nanoid(8)
|
||||
await db.insert(shortener).values({
|
||||
id: generateId(8),
|
||||
link: form.data.link,
|
||||
projectId: project?.id,
|
||||
userId: user.id,
|
||||
code: code,
|
||||
ios: form.data.ios,
|
||||
ios_link: form.data.ios_link,
|
||||
android: form.data.android,
|
||||
android_link: form.data.android_link,
|
||||
})
|
||||
|
||||
return { form }
|
||||
},
|
||||
}
|
||||
@ -1,375 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import * as Select from '$lib/components/ui/select/index.js'
|
||||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import * as Drawer from '$lib/components/ui/drawer'
|
||||
import * as Pagination from '$lib/components/ui/pagination'
|
||||
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
SortAscIcon,
|
||||
} from 'lucide-svelte'
|
||||
|
||||
import ShortenerCard from '$lib/components/ShortenerCard.svelte'
|
||||
import Form from './(components)/form.svelte'
|
||||
import ProjectLinkQRPage from '../projects/[id]/links/[linkid]/qr/+page.svelte'
|
||||
import LinkQRPage from './[id]/qr/+page.svelte'
|
||||
import type { Project, Shortener } from '$lib/db/types'
|
||||
|
||||
let { data } = $props()
|
||||
|
||||
let dialogOpen = $state(false)
|
||||
|
||||
let search = $state<string | null>('')
|
||||
let searchUpdateTimeout = $state<any>()
|
||||
|
||||
let pageNumber = $state(1)
|
||||
let perPage = $state(12)
|
||||
let sortBy = $state('latest')
|
||||
let selectedProject = $state('all')
|
||||
|
||||
const fetchShorteners = async (
|
||||
page: number,
|
||||
perPage: number,
|
||||
sortBy: string,
|
||||
project: string,
|
||||
search: string | null,
|
||||
) => {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (page) searchParams.set('page', page.toString())
|
||||
if (perPage) searchParams.set('perPage', perPage.toString())
|
||||
if (sortBy) searchParams.set('sortBy', sortBy)
|
||||
if (project) searchParams.set('project', project)
|
||||
if (search) searchParams.set('search', search)
|
||||
|
||||
const response = await fetch(
|
||||
`/api/shortener?${searchParams.toString()}`,
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
shorteners: data.shorteners as (Shortener & {
|
||||
visitorCount: number
|
||||
project: Project
|
||||
})[],
|
||||
pagination: data.pagination as { total: number },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap-reverse items-center justify-start gap-4 p-4">
|
||||
<!-- <Drawer.Root>
|
||||
<Drawer.Trigger class="md:hidden">
|
||||
<Button size="icon"><SortAscIcon /></Button>
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Content>
|
||||
<Drawer.Header>
|
||||
<Drawer.Title>Filter</Drawer.Title>
|
||||
<Drawer.Description>Sort & Search</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
<Drawer.Footer class="gap-6">
|
||||
<Select.Root
|
||||
selected={sortBy}
|
||||
onSelectedChange={(selected) => {
|
||||
if (!selected) return
|
||||
sortBy = selected
|
||||
pageNumber = 1
|
||||
}}>
|
||||
<Select.Trigger>
|
||||
<Select.Value placeholder="Sort By" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Label>Sort By</Select.Label>
|
||||
{#each [{ label: 'Latest', value: 'latest' }, { label: 'Oldest', value: 'oldest' }, { label: 'Most Visited', value: 'most_visited' }] as sortBy}
|
||||
<Select.Item
|
||||
value={sortBy.value}
|
||||
label={sortBy.label}>
|
||||
{sortBy.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
<Select.Input name="favoriteFruit" />
|
||||
</Select.Root>
|
||||
{#await data.projects}
|
||||
<Skeleton class="h-[40px] w-[180px]" />
|
||||
{:then projects}
|
||||
<Select.Root
|
||||
selected={selectedProject}
|
||||
onSelectedChange={(selected) => {
|
||||
if (!selected) return
|
||||
selectedProject = selected
|
||||
pageNumber = 1
|
||||
}}>
|
||||
<Select.Trigger>
|
||||
<Select.Value placeholder="Sort By" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Label>Project</Select.Label>
|
||||
<Select.Separator />
|
||||
<Select.Item value={'all'} label={'All'}>
|
||||
All
|
||||
</Select.Item>
|
||||
<Select.Separator />
|
||||
{#each projects as project}
|
||||
<Select.Item
|
||||
value={project.id}
|
||||
label={project.name}>
|
||||
{project.name}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
<Select.Input name="favoriteFruit" />
|
||||
</Select.Root>
|
||||
{/await}
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="search"
|
||||
autofocus
|
||||
value={search}
|
||||
oninput={({ target }) => {
|
||||
clearTimeout(searchUpdateTimeout)
|
||||
searchUpdateTimeout = setTimeout(() => {
|
||||
search = target.value
|
||||
pageNumber = 1
|
||||
}, 500)
|
||||
}} />
|
||||
<Button disabled={!search} onclick={() => (search = '')}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<div></div>
|
||||
<Drawer.Close>
|
||||
<Button class="w-full">Close</Button>
|
||||
</Drawer.Close>
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root> -->
|
||||
<div class="hidden items-center gap-4 md:flex">
|
||||
{#await data.projects}
|
||||
<Skeleton class="h-[40px] w-[180px]" />
|
||||
{:then projects}
|
||||
<Select.Root
|
||||
name="selected_project"
|
||||
type="single"
|
||||
bind:value={selectedProject}>
|
||||
<Select.Trigger class="w-[180px]">
|
||||
{projects.find((project) => project.id == selectedProject)
|
||||
?.name || 'All'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.GroupHeading>Project</Select.GroupHeading>
|
||||
<Select.Separator />
|
||||
<Select.Item value={'all'} label={'All'}>All</Select.Item>
|
||||
<Select.Separator />
|
||||
{#each projects as project}
|
||||
<Select.Item value={project.id} label={project.name}>
|
||||
{project.name}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/await}
|
||||
<Select.Root name="sort_by" type="single" bind:value={sortBy}>
|
||||
<Select.Trigger class="w-[180px]">
|
||||
{sortBy}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.GroupHeading>Sort By</Select.GroupHeading>
|
||||
{#each [{ label: 'Latest', value: 'latest' }, { label: 'Oldest', value: 'oldest' }, { label: 'Most Visited', value: 'most_visited' }] as sortBy}
|
||||
<Select.Item value={sortBy.value}>
|
||||
{sortBy.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="hidden items-center gap-4 sm:flex">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="search"
|
||||
class="max-w-[250px]"
|
||||
autofocus
|
||||
value={search}
|
||||
oninput={({ target }) => {
|
||||
clearTimeout(searchUpdateTimeout)
|
||||
searchUpdateTimeout = setTimeout(() => {
|
||||
search = target.value
|
||||
pageNumber = 1
|
||||
}, 500)
|
||||
}} />
|
||||
<Button disabled={!search} onclick={() => (search = '')}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
{#await data.projects then projects}
|
||||
<Form bind:dialogOpen data={data.form} {projects} />
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
{#await fetchShorteners(pageNumber, perPage, sortBy, selectedProject, search)}
|
||||
<div class="flex-grow">
|
||||
<div class="flex flex-wrap gap-4 p-4">
|
||||
{#each [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as _}
|
||||
<Skeleton class="h-[150px] w-[500px] rounded-lg" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:then result}
|
||||
{#if result.shorteners.length > 0}
|
||||
<ScrollArea class="flex-grow">
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 p-4 md:grid-cols-[repeat(auto-fit,_minmax(500px,_1fr))]">
|
||||
{#each result.shorteners as shortener}
|
||||
<ShortenerCard
|
||||
{shortener}
|
||||
project={shortener.project}
|
||||
shortener_url={data.shortener_url} />
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div class="flex items-center justify-between border-t p-4">
|
||||
<Select.Root
|
||||
name="page_size"
|
||||
type="single"
|
||||
value={perPage.toString()}
|
||||
onValueChange={(value) => {
|
||||
perPage = parseInt(value)
|
||||
pageNumber = 1
|
||||
}}>
|
||||
<Select.Trigger class="w-[180px]">
|
||||
{perPage}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.GroupHeading>Page Size</Select.GroupHeading>
|
||||
{#each [12, 24, 48, 96] as pageSize}
|
||||
<Select.Item value={pageSize.toString()}>
|
||||
{pageSize}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Pagination.Root
|
||||
class="items-end"
|
||||
count={result.pagination.total}
|
||||
{perPage}
|
||||
bind:page={pageNumber}>
|
||||
{#snippet children({ pages, currentPage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
<span class="hidden sm:block">Previous</span>
|
||||
</Pagination.PrevButton>
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link
|
||||
{page}
|
||||
isActive={currentPage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton>
|
||||
<span class="hidden sm:block">Next</span>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Pagination.NextButton>
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-grow p-4">
|
||||
<div
|
||||
class="flex flex-1 items-center justify-center rounded-lg border border-dashed shadow-sm">
|
||||
<div
|
||||
class="flex w-full flex-grow items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="text-4xl font-bold">No Shortener Found</div>
|
||||
<p class="text-muted-foreground">Add a new shortener</p>
|
||||
</div>
|
||||
<Button
|
||||
onclick={() => {
|
||||
dialogOpen = true
|
||||
}}
|
||||
class="w-fit">
|
||||
Add Shortener
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
<Dialog.Root
|
||||
open={!!$page.state.linkQR}
|
||||
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)]">
|
||||
<LinkQRPage data={$page.state.linkQR} shallowRouting />
|
||||
</ScrollArea>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Dialog.Root
|
||||
open={!!$page.state.projectLinkQR}
|
||||
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>
|
||||
@ -1,204 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Form from '$lib/components/ui/form'
|
||||
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'
|
||||
|
||||
let {
|
||||
data,
|
||||
code,
|
||||
}: { data: SuperValidated<Infer<FormSchema>>; code: string } =
|
||||
$props()
|
||||
|
||||
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 = $state()
|
||||
let previewData: any = $state()
|
||||
let isPreviewLoading: boolean = $state(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 items-center gap-4 pb-4">
|
||||
<div class="font-bold">Preview</div>
|
||||
<div class="col-span-4 flex flex-col justify-center border">
|
||||
<div class="relative h-64 overflow-hidden">
|
||||
{#if isPreviewLoading}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
{:else if previewData}
|
||||
<img
|
||||
src={previewData.image}
|
||||
alt=""
|
||||
class="h-64 w-full object-cover" />
|
||||
<div
|
||||
class="bg-secondary absolute bottom-2 left-2 rounded-lg px-2">
|
||||
{previewData.title}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance
|
||||
class="flex flex-col gap-6"
|
||||
action={`/dashboard/links/${code}/edit`}>
|
||||
<Form.Field {form} name="link" class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Link</Form.Label>
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.link}
|
||||
placeholder="https://example.com"
|
||||
oninput={getMetadata} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>Shortener link</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field
|
||||
{form}
|
||||
name="custom_code_enable"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Switch
|
||||
{...props}
|
||||
bind:checked={$formData.custom_code_enable} />
|
||||
<Form.Label>Custom Code</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
{#if $formData.custom_code_enable}
|
||||
<Form.Field {form} name="custom_code" class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.custom_code}
|
||||
placeholder="abcde" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
Custom Code For The Shortener
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="ios"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Switch {...props} bind:checked={$formData.ios} />
|
||||
<Form.Label>iOS Link</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
{#if $formData.ios}
|
||||
<Form.Field {form} name="ios_link" class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.ios_link}
|
||||
placeholder="https://example.com" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>Shortener link for iOS</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="android"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Switch {...props} bind:checked={$formData.android} />
|
||||
<Form.Label>Android Link</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
{#if $formData.android}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="android_link"
|
||||
class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.android_link}
|
||||
placeholder="https://example.com" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>Shortener link for Android</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="active"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Checkbox {...props} bind:checked={$formData.active} />
|
||||
<Form.Label>Active</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Button class="w-fit">
|
||||
{#if $submitting}
|
||||
<LoaderCircle class="animate-spin" />
|
||||
{/if}
|
||||
Save
|
||||
</Form.Button>
|
||||
</form>
|
||||
@ -1,116 +0,0 @@
|
||||
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'
|
||||
import { isAlphanumeric } from '$lib/utils'
|
||||
|
||||
export const load = (async (event) => {
|
||||
const user = event.locals.user
|
||||
const { id } = event.params
|
||||
|
||||
const shortener = await db.query.shortener.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
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, `/dashboard/links`)
|
||||
}
|
||||
|
||||
return {
|
||||
shortener,
|
||||
form: await superValidate(
|
||||
{
|
||||
...shortener,
|
||||
custom_code_enable: true,
|
||||
custom_code: shortener.code,
|
||||
},
|
||||
zod(formSchema),
|
||||
{ errors: false },
|
||||
),
|
||||
}
|
||||
}) satisfies PageServerLoad
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async (event) => {
|
||||
const form = await superValidate(event, zod(formSchema))
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form,
|
||||
})
|
||||
}
|
||||
|
||||
const user = event.locals.user
|
||||
|
||||
if (form.data.custom_code_enable) {
|
||||
if (!form.data.custom_code) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Please Enter Custom Code',
|
||||
)
|
||||
}
|
||||
if (!isAlphanumeric(form.data.custom_code)) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Code cannot contain special characters',
|
||||
)
|
||||
}
|
||||
|
||||
const customCodeExist = await db.query.shortener.findMany({
|
||||
where: (shortener, { eq, and, ne }) =>
|
||||
and(
|
||||
eq(shortener.code, form.data.custom_code),
|
||||
ne(shortener.id, event.params.id),
|
||||
),
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
})
|
||||
|
||||
for (const shortener of customCodeExist) {
|
||||
if (!shortener.project) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Duplicated Custom Code',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(shortener)
|
||||
.set({
|
||||
link: form.data.link,
|
||||
userId: user.id,
|
||||
code: form.data.custom_code_enable
|
||||
? form.data.custom_code
|
||||
: undefined,
|
||||
ios: form.data.ios,
|
||||
ios_link: form.data.ios_link,
|
||||
android: form.data.android,
|
||||
android_link: form.data.android_link,
|
||||
})
|
||||
.where(eq(shortener.id, event.params.id))
|
||||
|
||||
return { form }
|
||||
},
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import type { PageData } from './$types'
|
||||
import Form from './(components)/form.svelte'
|
||||
|
||||
let {
|
||||
data,
|
||||
shallowRouting = $bindable(false),
|
||||
}: { data: PageData; shallowRouting: boolean } = $props()
|
||||
</script>
|
||||
|
||||
{#if !shallowRouting}
|
||||
<ScrollArea>
|
||||
<div class="max-w-2xl px-10 py-4">
|
||||
<Form data={data.form} code={data.shortener.id} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{:else}
|
||||
<Form data={data.form} code={data.shortener.id} />
|
||||
{/if}
|
||||
@ -1,20 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const formSchema = z.object({
|
||||
link: z.string().url(),
|
||||
active: z.boolean(),
|
||||
ios: z.boolean(),
|
||||
ios_link: z
|
||||
.union([z.literal(''), z.string().url()])
|
||||
.optional()
|
||||
.nullable(),
|
||||
android: z.boolean(),
|
||||
android_link: z
|
||||
.union([z.literal(''), z.string().url()])
|
||||
.optional()
|
||||
.nullable(),
|
||||
custom_code_enable: z.boolean(),
|
||||
custom_code: z.string(),
|
||||
})
|
||||
|
||||
export type FormSchema = typeof formSchema
|
||||
@ -1,116 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { browser } from '$app/environment'
|
||||
import { cn } from '$lib/utils'
|
||||
|
||||
let {
|
||||
background = '#fff',
|
||||
color = '#000',
|
||||
value = '',
|
||||
code = '',
|
||||
cornerSquareStyle = 'square',
|
||||
dotStyle = 'square',
|
||||
existingQrImage = null,
|
||||
}: {
|
||||
background: string
|
||||
color: string
|
||||
value: string
|
||||
code: string
|
||||
cornerSquareStyle: 'dot' | 'square' | 'extra-rounded'
|
||||
dotStyle: 'square' | 'rounded'
|
||||
existingQrImage: string | null
|
||||
} = $props()
|
||||
|
||||
let image = $state('')
|
||||
|
||||
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() {
|
||||
const qrcodestyling = new (
|
||||
await import('qr-code-styling')
|
||||
).default({
|
||||
data: value,
|
||||
width: 300,
|
||||
height: 300,
|
||||
margin: 1,
|
||||
qrOptions: {
|
||||
errorCorrectionLevel: 'M',
|
||||
typeNumber: 0,
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: background,
|
||||
},
|
||||
dotsOptions: {
|
||||
color: color,
|
||||
type: dotStyle,
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: cornerSquareStyle,
|
||||
},
|
||||
image: existingQrImage || undefined,
|
||||
imageOptions: {
|
||||
imageSize: 0.7,
|
||||
margin: 8,
|
||||
},
|
||||
})
|
||||
const blob = await qrcodestyling.getRawData()
|
||||
if (!blob) return
|
||||
image = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (value && browser) {
|
||||
generateQrCode()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col items-center gap-4">
|
||||
<Badge variant="secondary">
|
||||
{value}
|
||||
</Badge>
|
||||
<img src={image} alt={value} width={300} height={300} />
|
||||
<div class="flex w-full gap-4">
|
||||
<Button class="w-full" onclick={copyImageToClipboard}>
|
||||
Copy Image
|
||||
</Button>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class={cn(buttonVariants({ variant: 'default' }), 'w-full')}>
|
||||
QR Link
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<a href={`/url/${code}/qr`} target="_blank">
|
||||
<DropdownMenu.Item>Standard</DropdownMenu.Item>
|
||||
</a>
|
||||
<a href={`/url/${code}/qr?color=true`} target="_blank">
|
||||
<DropdownMenu.Item>With Style</DropdownMenu.Item>
|
||||
</a>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,22 +0,0 @@
|
||||
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, `/dashboard/links`)
|
||||
}
|
||||
|
||||
return { shortener }
|
||||
}) satisfies PageServerLoad
|
||||
@ -1,40 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import type { PageData } from './$types'
|
||||
import QR from './(components)/qr.svelte'
|
||||
|
||||
let {
|
||||
data,
|
||||
shallowRouting = $bindable(false),
|
||||
}: { data: PageData; shallowRouting: boolean } = $props()
|
||||
</script>
|
||||
|
||||
{#if !shallowRouting}
|
||||
<ScrollArea>
|
||||
<div class="max-w-2xl px-10 py-4">
|
||||
<QR
|
||||
value={'https://' +
|
||||
data.shortener_url +
|
||||
'/' +
|
||||
data.shortener.code}
|
||||
code={data.shortener.code}
|
||||
background={data.user.qrBackground}
|
||||
color={data.user.qrForeground}
|
||||
cornerSquareStyle={data.user.qrCornerSquareStyle}
|
||||
dotStyle={data.user.qrDotStyle}
|
||||
existingQrImage={data.user.qrImageBase64} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{:else}
|
||||
<QR
|
||||
value={'https://' +
|
||||
data.shortener_url +
|
||||
'/' +
|
||||
data.shortener.code}
|
||||
code={data.shortener.code}
|
||||
background={data.user.qrBackground}
|
||||
color={data.user.qrForeground}
|
||||
cornerSquareStyle={data.user.qrCornerSquareStyle}
|
||||
dotStyle={data.user.qrDotStyle}
|
||||
existingQrImage={data.user.qrImageBase64} />
|
||||
{/if}
|
||||
@ -1,15 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const formSchema = z.object({
|
||||
link: z.string().url(),
|
||||
project: z.string().optional(),
|
||||
active: z.boolean(),
|
||||
ios: z.boolean(),
|
||||
ios_link: z.string().url().optional(),
|
||||
android: z.boolean(),
|
||||
android_link: z.string().url().optional(),
|
||||
custom_code_enable: z.boolean(),
|
||||
custom_code: z.string(),
|
||||
})
|
||||
|
||||
export type FormSchema = typeof formSchema
|
||||
@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import { Progress } from '$lib/components/ui/progress'
|
||||
import { byteToHumanReadable, cn } from '$lib/utils'
|
||||
import { RotateCwIcon, XIcon } from 'lucide-svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import { toast } from 'svelte-sonner'
|
||||
|
||||
let {
|
||||
file,
|
||||
uploadUrl,
|
||||
delete: deleteCard,
|
||||
}: { file: File; uploadUrl: string; delete: () => void } = $props()
|
||||
|
||||
let uploadProgress = $state(0)
|
||||
let uploadMax = $state(1)
|
||||
|
||||
let isCancelled = $state(false)
|
||||
let isCompleted = $state(false)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
const upload = async () => {
|
||||
// return
|
||||
toast.info(`Uploading: ${file.name}`)
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
fileType: file.type,
|
||||
}),
|
||||
})
|
||||
|
||||
const body = await response.json()
|
||||
|
||||
// https://stackoverflow.com/questions/35711724/upload-progress-indicators-for-fetch
|
||||
const success = await new Promise((resolve) => {
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
uploadProgress = event.loaded
|
||||
uploadMax = event.total
|
||||
}
|
||||
})
|
||||
xhr.addEventListener('loadend', () => {
|
||||
resolve(xhr.readyState === 4 && xhr.status === 200)
|
||||
})
|
||||
xhr.open('PUT', body.link, true)
|
||||
xhr.setRequestHeader('Content-Type', file.type)
|
||||
xhr.send(file)
|
||||
})
|
||||
console.log('success:', success)
|
||||
|
||||
if ((success as boolean) === true) {
|
||||
isCompleted = true
|
||||
setTimeout(() => {
|
||||
invalidateAll()
|
||||
}, 3000)
|
||||
toast.success(`File Uploaded: ${file.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isCompleted) {
|
||||
setTimeout(() => {
|
||||
deleteCard()
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
upload()
|
||||
})
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Content class="flex items-center justify-between gap-4">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div>
|
||||
<span>
|
||||
{file.name}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
{byteToHumanReadable(file.size)}
|
||||
</span>
|
||||
{#if isCancelled}
|
||||
<span class="text-destructive">(Cancelled)</span>
|
||||
{:else}
|
||||
<span
|
||||
class={cn(
|
||||
'text-brand',
|
||||
uploadProgress / uploadMax === 1 && 'text-success',
|
||||
)}>
|
||||
({((uploadProgress / uploadMax) * 100).toFixed(1)} %)
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Progress value={uploadProgress} max={uploadMax} class="h-2" />
|
||||
</div>
|
||||
{#if !isCancelled || isCompleted}
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isCompleted}
|
||||
onclick={() => {
|
||||
xhr.abort()
|
||||
isCancelled = true
|
||||
toast.error(`Upload Cancelled: ${file.name}`)
|
||||
}}
|
||||
size="icon">
|
||||
<XIcon />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
upload()
|
||||
isCancelled = false
|
||||
toast.info(`Upload Retry: ${file.name}`)
|
||||
}}
|
||||
size="icon">
|
||||
<RotateCwIcon />
|
||||
</Button>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@ -0,0 +1,35 @@
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { db } from '$lib/db'
|
||||
|
||||
export const load = (async (event) => {
|
||||
const user = event.locals.user
|
||||
|
||||
const { activeProjectId, breadcrumbs: parentBreadcrumbs } =
|
||||
await event.parent()
|
||||
|
||||
const breadcrumbs = [
|
||||
...parentBreadcrumbs,
|
||||
{
|
||||
name: 'Files',
|
||||
path: `/dashboard/project/${activeProjectId}/file_uploads`,
|
||||
},
|
||||
]
|
||||
|
||||
const files = db.query.file.findMany({
|
||||
where: (file, { and, eq, isNull }) =>
|
||||
and(
|
||||
eq(file.userId, user.id),
|
||||
activeProjectId !== 'personal'
|
||||
? eq(file.projectId, activeProjectId)
|
||||
: isNull(file.projectId),
|
||||
),
|
||||
with: {
|
||||
shortener: true,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
files,
|
||||
breadcrumbs,
|
||||
}
|
||||
}) satisfies PageServerLoad
|
||||
@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button'
|
||||
import Dropzone from 'svelte-file-dropzone'
|
||||
import type { PageData } from './$types'
|
||||
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte'
|
||||
import * as Card from '$lib/components/ui/card/index.js'
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'
|
||||
import {
|
||||
BarChartIcon,
|
||||
EllipsisVerticalIcon,
|
||||
ExternalLinkIcon,
|
||||
ImageIcon,
|
||||
Key,
|
||||
Loader2Icon,
|
||||
QrCodeIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from 'lucide-svelte'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import {
|
||||
goto,
|
||||
invalidateAll,
|
||||
preloadData,
|
||||
pushState,
|
||||
} from '$app/navigation'
|
||||
import { byteToHumanReadable, cn } from '$lib/utils'
|
||||
import UploadFileCard from './(components)/upload-file-card.svelte'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
import { flip } from 'svelte/animate'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js'
|
||||
import ProjectLinkQRPage from '../links/[linkid]/qr/+page.svelte'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
let files = $state<{ accepted: File[]; rejected: File[] }>({
|
||||
accepted: [],
|
||||
rejected: [],
|
||||
})
|
||||
|
||||
let deleteDialogOpen = $state(false)
|
||||
let deleteLoading = $state(false)
|
||||
let deleteKey = $state('')
|
||||
|
||||
function handleFilesSelect(e: CustomEvent<any>) {
|
||||
const { acceptedFiles, fileRejections } = e.detail
|
||||
files.accepted = [...files.accepted, ...acceptedFiles]
|
||||
files.rejected = [...files.rejected, ...fileRejections]
|
||||
}
|
||||
|
||||
const deleteFile = async (key: string) => {
|
||||
deleteLoading = true
|
||||
const response = await fetch(
|
||||
`/api/project/${data.activeProjectId}/file/${key}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
if (body.success) {
|
||||
invalidateAll()
|
||||
toast.success(`File Deleted: ${key}`)
|
||||
}
|
||||
deleteLoading = false
|
||||
deleteDialogOpen = false
|
||||
}
|
||||
|
||||
const getFileDetails = async (key: string) => {
|
||||
const response = await fetch(
|
||||
`/api/project/${data.activeProjectId}/file/${key}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
console.log(body)
|
||||
}
|
||||
|
||||
let isLoadingQrModal = $state(false)
|
||||
const showQRModal = async (e: MouseEvent) => {
|
||||
isLoadingQrModal = true
|
||||
const { href } = e.currentTarget as HTMLAnchorElement
|
||||
|
||||
if (innerWidth < 640) goto(href)
|
||||
|
||||
const result = await preloadData(href)
|
||||
|
||||
if (result.type === 'loaded' && result.status === 200) {
|
||||
pushState(href, { newProjectLinkQR: result.data })
|
||||
} else {
|
||||
goto(href)
|
||||
}
|
||||
isLoadingQrModal = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<ScrollArea>
|
||||
<div class="flex flex-col gap-8 p-4">
|
||||
<Dropzone
|
||||
on:drop={(event) => handleFilesSelect(event)}
|
||||
class={'border-muted-foreground/25 hover:bg-muted/25 ring-offset-background focus-visible:ring-ring group relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed px-5 py-2.5 text-center transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2'}
|
||||
maxSize={5_000_000_000}
|
||||
disableDefaultStyles>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-4 sm:px-5">
|
||||
<div class="rounded-full border border-dashed p-3">
|
||||
<UploadIcon class="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-px">
|
||||
<p class="text-muted-foreground font-medium">
|
||||
Drag 'n' drop files here, or click to select files
|
||||
</p>
|
||||
<p class="text-muted-foreground/70 text-sm">
|
||||
You can upload multiple files (up to 5 GB each)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dropzone>
|
||||
{#if files.accepted.length > 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each files.accepted as acceptedFile, index (acceptedFile)}
|
||||
<div animate:flip in:fade out:fly={{ x: 100 }}>
|
||||
<UploadFileCard
|
||||
file={acceptedFile}
|
||||
uploadUrl={`/api/project/${data.activeProjectId}/file`}
|
||||
delete={() => {
|
||||
files.accepted.splice(index, 1)
|
||||
}} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Uploaded files</Card.Title>
|
||||
<Card.Description>
|
||||
View the uploaded files here
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#await data.files}
|
||||
<Skeleton class="h-16" />
|
||||
{:then files}
|
||||
{#if files}
|
||||
<div
|
||||
class="grid grid-cols-[repeat(auto-fit,_minmax(350px,_1fr))] gap-3">
|
||||
{#each files as file}
|
||||
<Card.Root>
|
||||
<Card.Content
|
||||
class="flex h-full items-end justify-between gap-2">
|
||||
<div
|
||||
class="flex h-full flex-1 flex-col gap-4 overflow-hidden text-ellipsis">
|
||||
<div>
|
||||
<span>
|
||||
{file.name}
|
||||
</span>
|
||||
<div class="text-muted-foreground">
|
||||
{byteToHumanReadable(file.size)}
|
||||
</div>
|
||||
<div
|
||||
class="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<a
|
||||
href={'https://' +
|
||||
data.shortener_url +
|
||||
'/' +
|
||||
file.shortener.code}
|
||||
target="_blank"
|
||||
class="hover:underline">
|
||||
{data.shortener_url +
|
||||
'/' +
|
||||
file.shortener.code}
|
||||
</a>
|
||||
<ExternalLinkIcon size={16} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
href={`/dashboard/project/${data.activeProjectId}/links/${file.shortener.id}`}
|
||||
class="bg-secondary flex h-8 items-center justify-center gap-1 rounded text-sm">
|
||||
<BarChartIcon size={20} />
|
||||
<div>Analytics</div>
|
||||
</Button>
|
||||
<a
|
||||
class={cn(
|
||||
buttonVariants({ variant: 'default' }),
|
||||
'bg-secondary flex h-8 items-center justify-center gap-1 rounded text-sm',
|
||||
)}
|
||||
href={`/dashboard/project/${data.activeProjectId}/links/${file.shortener.id}/qr`}
|
||||
onclick={(event) => {
|
||||
event.preventDefault()
|
||||
showQRModal(event)
|
||||
}}>
|
||||
{#if isLoadingQrModal}
|
||||
<Loader2Icon
|
||||
size={20}
|
||||
class="animate-spin" />
|
||||
{:else}
|
||||
<QrCodeIcon size={20} />
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<EllipsisVerticalIcon />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
getFileDetails(file.name)}>
|
||||
Details
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="text-destructive data-[highlighted]:bg-destructive flex items-center gap-2"
|
||||
onclick={() => {
|
||||
deleteKey = file.name
|
||||
deleteDialogOpen = true
|
||||
}}>
|
||||
Delete
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="text-card-foreground flex w-full flex-col items-center justify-center space-y-6 rounded-xl border bg-transparent p-16 shadow">
|
||||
<div
|
||||
class="mr-4 shrink-0 rounded-full border border-dashed p-4">
|
||||
<ImageIcon class="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-center gap-1.5 text-center">
|
||||
<h3 class="font-semibold leading-none tracking-tight">
|
||||
No files uploaded
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Upload some files to see them here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<Dialog.Root bind:open={deleteDialogOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header class="space-y-12">
|
||||
<div
|
||||
class="flex w-full flex-col items-center justify-center gap-6 pt-12">
|
||||
<div class="bg-destructive/30 w-fit rounded-full p-4">
|
||||
<TrashIcon class="text-destructive" size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Dialog.Title class="text-center">
|
||||
Delete File {deleteKey}?
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="text-center">
|
||||
Files And Their Shortener Will Be Permanently Deleted
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<div class="flex justify-center gap-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
onclick={() => {
|
||||
deleteDialogOpen = false
|
||||
}}
|
||||
disabled={deleteLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={() => deleteFile(deleteKey)}
|
||||
class="flex w-full gap-2"
|
||||
disabled={deleteLoading}>
|
||||
{#if deleteLoading}
|
||||
<Loader2Icon class="animate-spin" />
|
||||
{/if}
|
||||
Delete File
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Header>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Dialog.Root
|
||||
open={!!$page.state.newProjectLinkQR}
|
||||
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.newProjectLinkQR}
|
||||
shallowRouting />
|
||||
</ScrollArea>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@ -1,11 +0,0 @@
|
||||
import type { LayoutServerLoad } from './$types'
|
||||
|
||||
export const load = (async (event) => {
|
||||
const { breadcrumbs: parentBreadcrumbs } = await event.parent()
|
||||
const breadcrumbs = [
|
||||
...parentBreadcrumbs,
|
||||
{ name: 'Projects', path: '/dashboard/projects' },
|
||||
]
|
||||
const page_title = 'Projects'
|
||||
return { breadcrumbs, page_title }
|
||||
}) satisfies LayoutServerLoad
|
||||
@ -1,42 +0,0 @@
|
||||
import { db } from '$lib/db'
|
||||
import { message, superValidate } from 'sveltekit-superforms'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { zod } from 'sveltekit-superforms/adapters'
|
||||
import { formSchema } from './schema'
|
||||
import { fail, type Actions } from '@sveltejs/kit'
|
||||
import { project } from '$lib/db/schema'
|
||||
import { generateId } from 'lucia'
|
||||
|
||||
export const load = (async (event) => {
|
||||
const user = event.locals.user
|
||||
|
||||
const projects = await db.query.project.findMany({
|
||||
with: {
|
||||
shortener: true,
|
||||
},
|
||||
where: (project, { eq }) => eq(project.userId, user.id),
|
||||
})
|
||||
|
||||
return { projects, form: await superValidate(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,
|
||||
})
|
||||
}
|
||||
|
||||
const user = event.locals.user
|
||||
|
||||
await db.insert(project).values({
|
||||
id: generateId(8),
|
||||
name: form.data.name,
|
||||
userId: user.id,
|
||||
})
|
||||
|
||||
return message(form, 'Project created')
|
||||
},
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button'
|
||||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
import * as Form from '$lib/components/ui/form'
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import { ExternalLink, Loader2, PlusCircle } from 'lucide-svelte'
|
||||
|
||||
import { superForm } from 'sveltekit-superforms'
|
||||
import { formSchema } from './schema'
|
||||
import { zodClient } from 'sveltekit-superforms/adapters'
|
||||
import { toast } from 'svelte-sonner'
|
||||
|
||||
let { data } = $props()
|
||||
|
||||
let dialogOpen = $state(false)
|
||||
|
||||
const form = superForm(data.form, {
|
||||
validators: zodClient(formSchema),
|
||||
onUpdated: ({ form }) => {
|
||||
if (form.valid) {
|
||||
toast.success(form.message)
|
||||
dialogOpen = false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const { form: formData, enhance, submitting } = form
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-start p-4">
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Trigger
|
||||
class={buttonVariants({ variant: 'default' }) + 'flex gap-2'}>
|
||||
<PlusCircle />
|
||||
Add Project
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Add Project</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Create A New Project Here. Click Add To Create.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form method="POST" use:enhance>
|
||||
<Form.Field {form} name="name">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Name</Form.Label>
|
||||
<Input {...props} bind:value={$formData.name} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
Enter a name for your project.
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Form.Button>
|
||||
{#if $submitting}
|
||||
<Loader2 class="animate-spin" />
|
||||
{/if}
|
||||
Add
|
||||
</Form.Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
|
||||
{#if data.projects.length > 0}
|
||||
<div class="flex flex-wrap gap-4 overflow-scroll p-4">
|
||||
{#each data.projects as project}
|
||||
<a href={'/dashboard/projects/' + project.uuid}>
|
||||
<Card.Root
|
||||
class="hover:bg-secondary w-[500px] hover:cursor-pointer">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
{project.name}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="flex w-full justify-between">
|
||||
<Button
|
||||
class="bg-secondary flex h-8 items-center justify-center gap-1 rounded text-sm">
|
||||
<ExternalLink size={20} />
|
||||
{project.shortener.length}
|
||||
Shorteners
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-grow p-4">
|
||||
<div
|
||||
class="flex flex-1 items-center justify-center rounded-lg border border-dashed shadow-sm">
|
||||
<div class="flex w-full flex-grow items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="text-4xl font-bold">No Project Found</div>
|
||||
<p class="text-muted-foreground">Add a new project</p>
|
||||
</div>
|
||||
<Button
|
||||
onclick={() => {
|
||||
dialogOpen = true
|
||||
}}
|
||||
class="w-fit">
|
||||
Add Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@ -1,232 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Form from '$lib/components/ui/form'
|
||||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
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'
|
||||
|
||||
let {
|
||||
data,
|
||||
dialogOpen = $bindable(),
|
||||
}: {
|
||||
data: SuperValidated<Infer<FormSchema>>
|
||||
dialogOpen: boolean
|
||||
} = $props()
|
||||
|
||||
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 = $state()
|
||||
let previewData: any = $state()
|
||||
let isPreviewLoading: boolean = $state(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 items-center gap-4 pb-4">
|
||||
<div class="font-bold">Preview</div>
|
||||
<div class="col-span-4 flex flex-col justify-center border">
|
||||
<div class="relative h-64 overflow-hidden">
|
||||
{#if isPreviewLoading}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
{:else if previewData}
|
||||
<img
|
||||
src={previewData.image}
|
||||
alt=""
|
||||
class="h-64 w-full object-cover" />
|
||||
<div
|
||||
class="bg-secondary absolute bottom-2 left-2 rounded-lg px-2">
|
||||
{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>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Link</Form.Label>
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.link}
|
||||
placeholder="https://example.com"
|
||||
oninput={getMetadata} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>Shortener link</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field
|
||||
{form}
|
||||
name="custom_code_enable"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Switch
|
||||
{...props}
|
||||
bind:checked={$formData.custom_code_enable} />
|
||||
<Form.Label>Custom Code</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
{#if $formData.custom_code_enable}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="custom_code"
|
||||
class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.custom_code}
|
||||
placeholder="abcde" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
Custom Code For The Shortener
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="ios"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Switch {...props} bind:checked={$formData.ios} />
|
||||
<Form.Label>iOS Link</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
{#if $formData.ios}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="ios_link"
|
||||
class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.ios_link}
|
||||
placeholder="https://example.com" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
Shortener link for iOS
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="android"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Switch {...props} bind:checked={$formData.android} />
|
||||
<Form.Label>Android Link</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
{#if $formData.android}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="android_link"
|
||||
class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.android_link}
|
||||
placeholder="https://example.com" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
Shortener link for Android
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="active"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Checkbox {...props} bind:checked={$formData.active} />
|
||||
<Form.Label>Active</Form.Label>
|
||||
{/snippet}
|
||||
</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>
|
||||
@ -1,30 +0,0 @@
|
||||
import { db } from '$lib/db'
|
||||
import { redirect } from '@sveltejs/kit'
|
||||
import type { LayoutServerLoad } from './$types'
|
||||
|
||||
export const load = (async (event) => {
|
||||
const { id } = event.params
|
||||
try {
|
||||
const user = event.locals.user
|
||||
const project = await db.query.project.findFirst({
|
||||
where: (project, { eq, and }) =>
|
||||
and(eq(project.userId, user.id), eq(project.uuid, id)),
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
redirect(300, '/dashboard/projects')
|
||||
}
|
||||
|
||||
const { breadcrumbs: parentBreadcrumbs } = await event.parent()
|
||||
const breadcrumbs = [
|
||||
...parentBreadcrumbs,
|
||||
{
|
||||
name: project.name,
|
||||
path: `/dashboard/projects/${project.uuid}`,
|
||||
},
|
||||
]
|
||||
return { breadcrumbs, project }
|
||||
} catch (e) {
|
||||
redirect(300, '/dashboard/projects')
|
||||
}
|
||||
}) satisfies LayoutServerLoad
|
||||
@ -1,43 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
|
||||
let { data, children } = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex h-fit flex-col gap-2 px-4 pt-8 md:px-10">
|
||||
<div class="flex items-center gap-4 pb-4">
|
||||
<h2 class="pr-4 text-2xl font-bold tracking-tight md:pr-8">
|
||||
{data.project.name}
|
||||
</h2>
|
||||
<Separator orientation="vertical" />
|
||||
{#key $page.url.pathname}
|
||||
<ScrollArea orientation="horizontal">
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
href={`/dashboard/projects/${data.project.uuid}`}
|
||||
variant={$page.url.pathname ===
|
||||
`/dashboard/projects/${data.project.uuid}`
|
||||
? 'secondary'
|
||||
: 'ghost'}>
|
||||
Shorteners
|
||||
</Button>
|
||||
<Button
|
||||
href={`/dashboard/projects/${data.project.uuid}/settings`}
|
||||
variant={$page.url.pathname ===
|
||||
`/dashboard/projects/${data.project.uuid}/settings`
|
||||
? 'secondary'
|
||||
: 'ghost'}>
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-grow flex-col overflow-hidden">
|
||||
{@render children()}
|
||||
</div>
|
||||
@ -1,193 +0,0 @@
|
||||
import { db } from '$lib/db'
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
getTableColumns,
|
||||
ilike,
|
||||
sql,
|
||||
} from 'drizzle-orm'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { 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'
|
||||
import { isAlphanumeric } from '$lib/utils'
|
||||
import { generateId } from 'lucia'
|
||||
|
||||
export const load = (async (event) => {
|
||||
const { project: selectedProject } = await event.parent()
|
||||
|
||||
const user = event.locals.user
|
||||
|
||||
const search = event.url.searchParams.get('search')
|
||||
let sortBy = event.url.searchParams.get('sortBy')
|
||||
let page = parseInt(event.url.searchParams.get('page') ?? '1')
|
||||
let perPage = parseInt(
|
||||
event.url.searchParams.get('perPage') ?? '12',
|
||||
)
|
||||
|
||||
if (isNaN(page)) {
|
||||
page = 1
|
||||
}
|
||||
|
||||
if (isNaN(perPage)) {
|
||||
perPage = 10
|
||||
}
|
||||
|
||||
if (
|
||||
sortBy !== 'latest' &&
|
||||
sortBy !== 'oldest' &&
|
||||
sortBy !== 'most_visited'
|
||||
) {
|
||||
sortBy = 'latest'
|
||||
}
|
||||
|
||||
const shortenerColumns = getTableColumns(shortener)
|
||||
const shorteners = db
|
||||
.select({
|
||||
...shortenerColumns,
|
||||
visitorCount: sql<number>`count(${visitor.id})`,
|
||||
})
|
||||
.from(shortener)
|
||||
.where(
|
||||
and(
|
||||
eq(shortener.userId, user.id),
|
||||
eq(shortener.projectId, selectedProject.id),
|
||||
search
|
||||
? ilike(shortener.link, `%${decodeURI(search)}%`)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.leftJoin(visitor, eq(shortener.id, visitor.shortenerId))
|
||||
.groupBy(shortener.id)
|
||||
.offset(perPage * (page - 1))
|
||||
.limit(perPage)
|
||||
|
||||
if (sortBy === 'latest') {
|
||||
shorteners.orderBy(desc(shortener.createdAt))
|
||||
} else if (sortBy === 'oldest') {
|
||||
shorteners.orderBy(asc(shortener.createdAt))
|
||||
} else if (sortBy === 'most_visited') {
|
||||
shorteners.orderBy(sql`count(${visitor.id}) desc`)
|
||||
}
|
||||
|
||||
const pagination = db
|
||||
.select({
|
||||
total: sql<number>`count(*)`.as('total'),
|
||||
})
|
||||
.from(shortener)
|
||||
.where(
|
||||
and(
|
||||
eq(shortener.userId, user.id),
|
||||
eq(shortener.projectId, selectedProject.id),
|
||||
search
|
||||
? ilike(shortener.link, `%${decodeURI(search)}%`)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
selectedProject,
|
||||
shorteners,
|
||||
page,
|
||||
perPage,
|
||||
search,
|
||||
sortBy,
|
||||
pagination,
|
||||
form: await superValidate({ active: true }, zod(formSchema), {
|
||||
errors: false,
|
||||
}),
|
||||
}
|
||||
}) satisfies PageServerLoad
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async (event) => {
|
||||
const form = await superValidate(event, zod(formSchema))
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form,
|
||||
})
|
||||
}
|
||||
|
||||
const { id } = event.params
|
||||
const user = event.locals.user
|
||||
const project = await db.query.project.findFirst({
|
||||
where: (project, { eq, and }) =>
|
||||
and(eq(project.userId, user.id), eq(project.uuid, id)),
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
return fail(400, {
|
||||
form,
|
||||
})
|
||||
}
|
||||
|
||||
if (form.data.custom_code_enable) {
|
||||
if (!form.data.custom_code) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Please Enter Custom Code',
|
||||
)
|
||||
}
|
||||
if (!isAlphanumeric(form.data.custom_code)) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Code cannot contain special characters',
|
||||
)
|
||||
}
|
||||
|
||||
const customCodeExist = await db.query.shortener.findMany({
|
||||
where: (shortener, { eq, and, ne }) =>
|
||||
and(eq(shortener.code, form.data.custom_code)),
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
})
|
||||
|
||||
for (const shortener of customCodeExist) {
|
||||
if (!shortener.project && !project.enable_custom_domain) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Duplicated Custom Code',
|
||||
)
|
||||
}
|
||||
|
||||
if (!shortener.project) continue
|
||||
|
||||
if (
|
||||
shortener.project.custom_domain === project.custom_domain
|
||||
) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Duplicated Custom Code',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const code = form.data.custom_code_enable
|
||||
? form.data.custom_code
|
||||
: nanoid(8)
|
||||
await db.insert(shortener).values({
|
||||
id: generateId(8),
|
||||
link: form.data.link,
|
||||
projectId: project.id,
|
||||
userId: user.id,
|
||||
code: code,
|
||||
ios: form.data.ios,
|
||||
ios_link: form.data.ios_link,
|
||||
android: form.data.android,
|
||||
android_link: form.data.android_link,
|
||||
})
|
||||
|
||||
return { form }
|
||||
},
|
||||
}
|
||||
@ -1,266 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { browser } from '$app/environment'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
import ShortenerCard from '$lib/components/ShortenerCard.svelte'
|
||||
import CustomPaginationBar from '$lib/components/Custom-Pagination-Bar.svelte'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import * as Select from '$lib/components/ui/select'
|
||||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
import * as Drawer from '$lib/components/ui/drawer'
|
||||
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
} from 'lucide-svelte'
|
||||
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import Form from './(components)/form.svelte'
|
||||
import EditProjectLinkPage from './links/[linkid]/edit/+page.svelte'
|
||||
import ProjectLinkQRPage from './links/[linkid]/qr/+page.svelte'
|
||||
import type { Project, Shortener } from '$lib/db/types'
|
||||
import * as Pagination from '$lib/components/ui/pagination'
|
||||
|
||||
let { data } = $props()
|
||||
|
||||
let dialogOpen = $state(false)
|
||||
|
||||
let search = $state<string | null>(data.search)
|
||||
let searchUpdateTimeout = $state<any>()
|
||||
|
||||
let pageNumber = $state(1)
|
||||
let perPage = $state(12)
|
||||
let sortBy = $state('latest')
|
||||
const selectedProject = data.selectedProject.id
|
||||
|
||||
let editProjectLinkOpen = $state(false)
|
||||
let projectLinkQROpen = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
editProjectLinkOpen = !!$page.state.editProjectLink
|
||||
projectLinkQROpen = !!$page.state.projectLinkQR
|
||||
})
|
||||
|
||||
const fetchShorteners = async (
|
||||
page: number,
|
||||
perPage: number,
|
||||
sortBy: string,
|
||||
project: string,
|
||||
search: string | null,
|
||||
) => {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (page) searchParams.set('page', page.toString())
|
||||
if (perPage) searchParams.set('perPage', perPage.toString())
|
||||
if (sortBy) searchParams.set('sortBy', sortBy)
|
||||
if (project) searchParams.set('project', project)
|
||||
if (search) searchParams.set('search', search)
|
||||
|
||||
const response = await fetch(
|
||||
`/api/shortener?${searchParams.toString()}`,
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
shorteners: data.shorteners as (Shortener & {
|
||||
visitorCount: number
|
||||
project: Project
|
||||
})[],
|
||||
pagination: data.pagination as { total: number },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap-reverse items-center justify-start gap-4 px-4 py-4 md:px-10">
|
||||
<div class="hidden items-center gap-4 md:flex">
|
||||
<Select.Root bind:value={sortBy} type="single" name="sortBy">
|
||||
<Select.Trigger class="w-[180px]">
|
||||
{sortBy}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.GroupHeading>Sort By</Select.GroupHeading>
|
||||
{#each ['latest', 'oldest', 'most_visited'] as sortBy}
|
||||
<Select.Item value={sortBy} label={sortBy}>
|
||||
{sortBy.removeUnderscores().capitalize()}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="hidden items-center gap-4 sm:flex">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="search"
|
||||
class="max-w-[250px]"
|
||||
autofocus
|
||||
value={search}
|
||||
oninput={({ target }) => {
|
||||
clearTimeout(searchUpdateTimeout)
|
||||
searchUpdateTimeout = setTimeout(() => {
|
||||
search = target.value
|
||||
}, 500)
|
||||
}} />
|
||||
<Button disabled={!search} onclick={() => (search = '')}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<Form bind:dialogOpen data={data.form} />
|
||||
</div>
|
||||
|
||||
{#await fetchShorteners(pageNumber, perPage, sortBy, selectedProject, search)}
|
||||
<div class="flex flex-wrap gap-4 px-4 py-4 md:px-10">
|
||||
{#each [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as _}
|
||||
<Skeleton class="h-[150px] w-[500px] rounded-lg" />
|
||||
{/each}
|
||||
</div>
|
||||
{:then result}
|
||||
{#if result.shorteners.length > 0}
|
||||
<ScrollArea class="flex-grow">
|
||||
<div
|
||||
class="grid grid-cols-[repeat(auto-fit,_minmax(500px,_1fr))] gap-4 px-4 py-4 md:px-10">
|
||||
{#each result.shorteners as shortener}
|
||||
<ShortenerCard
|
||||
{shortener}
|
||||
project={data.project}
|
||||
shortener_url={data.shortener_url} />
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div class="flex items-center justify-between border-t p-4">
|
||||
<Select.Root
|
||||
name="page_size"
|
||||
type="single"
|
||||
value={perPage.toString()}
|
||||
onValueChange={(value) => {
|
||||
perPage = parseInt(value)
|
||||
pageNumber = 1
|
||||
}}>
|
||||
<Select.Trigger class="w-[180px]">
|
||||
{perPage}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.GroupHeading>Page Size</Select.GroupHeading>
|
||||
{#each [12, 24, 48, 96] as pageSize}
|
||||
<Select.Item value={pageSize.toString()}>
|
||||
{pageSize}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Pagination.Root
|
||||
class="items-end"
|
||||
count={result.pagination.total}
|
||||
{perPage}
|
||||
bind:page={pageNumber}>
|
||||
{#snippet children({ pages, currentPage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
<span class="hidden sm:block">Previous</span>
|
||||
</Pagination.PrevButton>
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link
|
||||
{page}
|
||||
isActive={currentPage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton>
|
||||
<span class="hidden sm:block">Next</span>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Pagination.NextButton>
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-grow px-4 py-4 md:px-10">
|
||||
<div
|
||||
class="flex flex-1 items-center justify-center rounded-lg border border-dashed shadow-sm">
|
||||
<div
|
||||
class="flex w-full flex-grow items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="text-4xl font-bold">No Shortener Found</div>
|
||||
<p class="text-muted-foreground">Add a new shortener</p>
|
||||
</div>
|
||||
<Button
|
||||
onclick={() => {
|
||||
dialogOpen = true
|
||||
}}
|
||||
class="w-fit">
|
||||
Add Shortener
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/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={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>
|
||||
@ -1,8 +0,0 @@
|
||||
<div class="flex justify-center items-center w-full h-full">
|
||||
<div class="flex flex-col gap-12 items-center">
|
||||
<div class="flex flex-col gap-4 items-center">
|
||||
<div class="text-4xl font-bold">404</div>
|
||||
<div class="text-4xl font-bold">Page Not Found</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,208 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Form from '$lib/components/ui/form'
|
||||
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'
|
||||
|
||||
let {
|
||||
data,
|
||||
code,
|
||||
uuid,
|
||||
}: {
|
||||
data: SuperValidated<Infer<FormSchema>>
|
||||
uuid: string
|
||||
code: string
|
||||
} = $props()
|
||||
|
||||
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 = $state()
|
||||
let previewData: any = $state()
|
||||
let isPreviewLoading: boolean = $state(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 items-center gap-4 pb-4">
|
||||
<div class="font-bold">Preview</div>
|
||||
<div class="col-span-4 flex flex-col justify-center border">
|
||||
<div class="relative h-64 overflow-hidden">
|
||||
{#if isPreviewLoading}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
{:else if previewData}
|
||||
<img
|
||||
src={previewData.image}
|
||||
alt=""
|
||||
class="h-64 w-full object-cover" />
|
||||
<div
|
||||
class="bg-secondary absolute bottom-2 left-2 rounded-lg px-2">
|
||||
{previewData.title}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance
|
||||
class="flex flex-col gap-6"
|
||||
action={`/dashboard/projects/${uuid}/links/${code}/edit`}>
|
||||
<Form.Field {form} name="link" class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Link</Form.Label>
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.link}
|
||||
placeholder="https://example.com"
|
||||
oninput={getMetadata} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>Shortener link</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field
|
||||
{form}
|
||||
name="custom_code_enable"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Switch
|
||||
{...props}
|
||||
bind:checked={$formData.custom_code_enable} />
|
||||
<Form.Label>Custom Code</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
{#if $formData.custom_code_enable}
|
||||
<Form.Field {form} name="custom_code" class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.custom_code}
|
||||
placeholder="abcde" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
Custom Code For The Shortener
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="ios"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Switch {...props} bind:checked={$formData.ios} />
|
||||
<Form.Label>iOS Link</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
{#if $formData.ios}
|
||||
<Form.Field {form} name="ios_link" class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.ios_link}
|
||||
placeholder="https://example.com" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>Shortener link for iOS</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="android"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Switch {...props} bind:checked={$formData.android} />
|
||||
<Form.Label>Android Link</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
{#if $formData.android}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="android_link"
|
||||
class="flex flex-col gap-2">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.android_link}
|
||||
placeholder="https://example.com" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>Shortener link for Android</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/if}
|
||||
<Form.Field
|
||||
{form}
|
||||
name="active"
|
||||
class="flex items-center gap-2 space-y-0">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Checkbox {...props} bind:checked={$formData.active} />
|
||||
<Form.Label>Active</Form.Label>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Button class="w-fit">
|
||||
{#if $submitting}
|
||||
<LoaderCircle class="animate-spin" />
|
||||
{/if}
|
||||
Save
|
||||
</Form.Button>
|
||||
</form>
|
||||
@ -1,141 +0,0 @@
|
||||
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'
|
||||
import { isAlphanumeric } from '$lib/utils'
|
||||
|
||||
export const load = (async (event) => {
|
||||
const { project: selectedProject } = await event.parent()
|
||||
const { linkid } = event.params
|
||||
|
||||
const shortener = await db.query.shortener.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
code: true,
|
||||
ios: true,
|
||||
ios_link: true,
|
||||
android: true,
|
||||
android_link: true,
|
||||
link: true,
|
||||
active: true,
|
||||
},
|
||||
where: (shortener, { eq, and }) =>
|
||||
and(
|
||||
eq(shortener.id, linkid),
|
||||
eq(shortener.projectId, selectedProject.id),
|
||||
),
|
||||
})
|
||||
|
||||
if (!shortener) {
|
||||
redirect(300, `/dashboard/projects/${selectedProject.id}`)
|
||||
}
|
||||
|
||||
return {
|
||||
shortener,
|
||||
form: await superValidate(
|
||||
{
|
||||
...shortener,
|
||||
custom_code_enable: true,
|
||||
custom_code: shortener.code,
|
||||
},
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
const { id } = event.params
|
||||
const user = event.locals.user
|
||||
const project = await db.query.project.findFirst({
|
||||
where: (project, { eq, and }) =>
|
||||
and(eq(project.userId, user.id), eq(project.uuid, id)),
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
return fail(400, {
|
||||
form,
|
||||
})
|
||||
}
|
||||
|
||||
if (form.data.custom_code_enable) {
|
||||
if (!form.data.custom_code) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Please Enter Custom Code',
|
||||
)
|
||||
}
|
||||
if (!isAlphanumeric(form.data.custom_code)) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Code cannot contain special characters',
|
||||
)
|
||||
}
|
||||
|
||||
const customCodeExist = await db.query.shortener.findMany({
|
||||
where: (shortener, { eq, and, ne }) =>
|
||||
and(
|
||||
eq(shortener.code, form.data.custom_code),
|
||||
ne(shortener.id, event.params.linkid),
|
||||
),
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
})
|
||||
|
||||
for (const shortener of customCodeExist) {
|
||||
if (!shortener.project && !project.enable_custom_domain) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Duplicated Custom Code',
|
||||
)
|
||||
}
|
||||
|
||||
if (!shortener.project) continue
|
||||
|
||||
if (
|
||||
shortener.project.custom_domain === project.custom_domain
|
||||
) {
|
||||
return setError(
|
||||
form,
|
||||
'custom_code',
|
||||
'Duplicated Custom Code',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(shortener)
|
||||
.set({
|
||||
link: form.data.link,
|
||||
projectId: project.id,
|
||||
userId: user.id,
|
||||
code: form.data.custom_code_enable
|
||||
? form.data.custom_code
|
||||
: undefined,
|
||||
ios: form.data.ios,
|
||||
ios_link: form.data.ios_link,
|
||||
android: form.data.android,
|
||||
android_link: form.data.android_link,
|
||||
})
|
||||
.where(eq(shortener.id, event.params.linkid))
|
||||
|
||||
return { form }
|
||||
},
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import type { PageData } from './$types'
|
||||
import Form from './(components)/form.svelte'
|
||||
|
||||
let {
|
||||
data,
|
||||
shallowRouting = $bindable(false),
|
||||
}: { data: PageData; shallowRouting: boolean } = $props()
|
||||
</script>
|
||||
|
||||
{#if !shallowRouting}
|
||||
<ScrollArea>
|
||||
<div class="max-w-2xl px-10 py-4">
|
||||
<Form
|
||||
data={data.form}
|
||||
uuid={data.project.uuid}
|
||||
code={data.shortener.id} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{:else}
|
||||
<Form
|
||||
data={data.form}
|
||||
uuid={data.project.uuid}
|
||||
code={data.shortener.id} />
|
||||
{/if}
|
||||
@ -1,21 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const formSchema = z.object({
|
||||
link: z.string().url(),
|
||||
project: z.string().optional(),
|
||||
active: z.boolean(),
|
||||
ios: z.boolean(),
|
||||
ios_link: z
|
||||
.union([z.literal(''), z.string().url()])
|
||||
.optional()
|
||||
.nullable(),
|
||||
android: z.boolean(),
|
||||
android_link: z
|
||||
.union([z.literal(''), z.string().url()])
|
||||
.optional()
|
||||
.nullable(),
|
||||
custom_code_enable: z.boolean(),
|
||||
custom_code: z.string(),
|
||||
})
|
||||
|
||||
export type FormSchema = typeof formSchema
|
||||
@ -1,116 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, buttonVariants } 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'
|
||||
import { browser } from '$app/environment'
|
||||
import { cn } from '$lib/utils'
|
||||
|
||||
let {
|
||||
background = '#fff',
|
||||
color = '#000',
|
||||
value = '',
|
||||
code = '',
|
||||
cornerSquareStyle = 'square',
|
||||
dotStyle = 'square',
|
||||
existingQrImage = null,
|
||||
}: {
|
||||
background: string
|
||||
color: string
|
||||
value: string
|
||||
code: string
|
||||
cornerSquareStyle: 'dot' | 'square' | 'extra-rounded'
|
||||
dotStyle: 'square' | 'rounded'
|
||||
existingQrImage: string | null
|
||||
} = $props()
|
||||
|
||||
let image = $state('')
|
||||
|
||||
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() {
|
||||
const qrcodestyling = new (
|
||||
await import('qr-code-styling')
|
||||
).default({
|
||||
data: value,
|
||||
width: 300,
|
||||
height: 300,
|
||||
margin: 1,
|
||||
qrOptions: {
|
||||
errorCorrectionLevel: 'M',
|
||||
typeNumber: 0,
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: background,
|
||||
},
|
||||
dotsOptions: {
|
||||
color: color,
|
||||
type: dotStyle,
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: cornerSquareStyle,
|
||||
},
|
||||
image: existingQrImage || undefined,
|
||||
imageOptions: {
|
||||
imageSize: 0.7,
|
||||
margin: 8,
|
||||
},
|
||||
})
|
||||
const blob = await qrcodestyling.getRawData()
|
||||
if (!blob) return
|
||||
image = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (value && browser) {
|
||||
generateQrCode()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col items-center gap-4">
|
||||
<Badge variant="secondary">
|
||||
{value}
|
||||
</Badge>
|
||||
<img src={image} alt={value} width={300} height={300} />
|
||||
<div class="flex w-full gap-4">
|
||||
<Button class="w-full" onclick={copyImageToClipboard}>
|
||||
Copy Image
|
||||
</Button>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class={cn(buttonVariants({ variant: 'default' }), 'w-full')}>
|
||||
QR Link
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<a href={`/url/${code}/qr`} target="_blank">
|
||||
<DropdownMenu.Item>Standard</DropdownMenu.Item>
|
||||
</a>
|
||||
<a href={`/url/${code}/qr?color=true`} target="_blank">
|
||||
<DropdownMenu.Item>With Style</DropdownMenu.Item>
|
||||
</a>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,25 +0,0 @@
|
||||
import { db } from '$lib/db'
|
||||
import { redirect } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load = (async (event) => {
|
||||
const { project: selectedProject } = await event.parent()
|
||||
const { linkid } = event.params
|
||||
|
||||
const shortener = await db.query.shortener.findFirst({
|
||||
columns: {
|
||||
code: true,
|
||||
},
|
||||
where: (shortener, { eq, and }) =>
|
||||
and(
|
||||
eq(shortener.code, linkid),
|
||||
eq(shortener.projectId, selectedProject.id),
|
||||
),
|
||||
})
|
||||
|
||||
if (!shortener) {
|
||||
redirect(300, `/dashboard/projects/${selectedProject.id}`)
|
||||
}
|
||||
|
||||
return { shortener, project: selectedProject }
|
||||
}) satisfies PageServerLoad
|
||||
@ -1,39 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import type { PageData } from './$types'
|
||||
import QR from './(components)/qr.svelte'
|
||||
|
||||
let {
|
||||
data,
|
||||
shallowRouting,
|
||||
}: { data: PageData; shallowRouting: boolean } = $props()
|
||||
|
||||
const url =
|
||||
data.project.enable_custom_domain && data.project.custom_domain
|
||||
? data.project.custom_domain
|
||||
: data.shortener_url
|
||||
</script>
|
||||
|
||||
{#if !shallowRouting}
|
||||
<ScrollArea>
|
||||
<div class="max-w-2xl px-10 py-4">
|
||||
<QR
|
||||
value={'https://' + url + '/' + data.shortener.code}
|
||||
code={data.shortener.code}
|
||||
background={data.project.qr_background}
|
||||
color={data.project.qr_foreground}
|
||||
cornerSquareStyle={data.project.qrCornerSquareStyle}
|
||||
dotStyle={data.project.qrDotStyle}
|
||||
existingQrImage={data.project.qrImageBase64} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{:else}
|
||||
<QR
|
||||
value={'https://' + url + '/' + data.shortener.code}
|
||||
code={data.shortener.code}
|
||||
background={data.project.qr_background}
|
||||
color={data.project.qr_foreground}
|
||||
cornerSquareStyle={data.project.qrCornerSquareStyle}
|
||||
dotStyle={data.project.qrDotStyle}
|
||||
existingQrImage={data.project.qrImageBase64} />
|
||||
{/if}
|
||||
@ -1,14 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const formSchema = z.object({
|
||||
link: z.string().url(),
|
||||
active: z.boolean(),
|
||||
ios: z.boolean(),
|
||||
ios_link: z.string().url().optional(),
|
||||
android: z.boolean(),
|
||||
android_link: z.string().url().optional(),
|
||||
custom_code_enable: z.boolean(),
|
||||
custom_code: z.string(),
|
||||
})
|
||||
|
||||
export type FormSchema = typeof formSchema
|
||||
@ -1,88 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { browser } from '$app/environment'
|
||||
|
||||
let {
|
||||
background = '#fff',
|
||||
color = '#000',
|
||||
value = 'example.com/abcdefgh',
|
||||
cornerSquareStyle = 'square',
|
||||
dotStyle = 'square',
|
||||
existingQrImage = null,
|
||||
qrImage = null,
|
||||
}: {
|
||||
background: string
|
||||
color: string
|
||||
value: string
|
||||
cornerSquareStyle: 'dot' | 'square' | 'extra-rounded'
|
||||
dotStyle: 'square' | 'rounded'
|
||||
existingQrImage: string | null
|
||||
qrImage: File | null
|
||||
} = $props()
|
||||
|
||||
let image = $state('')
|
||||
|
||||
async function generateQrCode() {
|
||||
if (!document || !window) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const qrImageDataUrl = qrImage
|
||||
? URL.createObjectURL(qrImage)
|
||||
: existingQrImage || undefined
|
||||
|
||||
const qrcodestyling = new (
|
||||
await import('qr-code-styling')
|
||||
).default({
|
||||
data: value,
|
||||
width: 300,
|
||||
height: 300,
|
||||
margin: 1,
|
||||
qrOptions: {
|
||||
errorCorrectionLevel: 'M',
|
||||
typeNumber: 0,
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: background,
|
||||
},
|
||||
dotsOptions: {
|
||||
color: color,
|
||||
type: dotStyle,
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: cornerSquareStyle,
|
||||
},
|
||||
image: qrImageDataUrl,
|
||||
imageOptions: {
|
||||
imageSize: 0.7,
|
||||
margin: 8,
|
||||
},
|
||||
})
|
||||
const blob = await qrcodestyling.getRawData()
|
||||
if (!blob) return
|
||||
image = URL.createObjectURL(blob)
|
||||
} catch (e) {
|
||||
image = ''
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (
|
||||
browser &&
|
||||
background &&
|
||||
color &&
|
||||
cornerSquareStyle &&
|
||||
dotStyle &&
|
||||
(qrImage === null || qrImage)
|
||||
) {
|
||||
generateQrCode()
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
generateQrCode()
|
||||
})
|
||||
</script>
|
||||
|
||||
<img src={image} alt={value} width={300} height={300} />
|
||||
@ -1,85 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js'
|
||||
import * as Card from '$lib/components/ui/card/index.js'
|
||||
|
||||
let {
|
||||
host,
|
||||
a_record,
|
||||
aaaa_record,
|
||||
cname_record,
|
||||
}: {
|
||||
host: string
|
||||
cname_record: string
|
||||
a_record: string
|
||||
aaaa_record: string
|
||||
} = $props()
|
||||
|
||||
const defaultValue = cname_record ? 'cname' : 'a'
|
||||
</script>
|
||||
|
||||
<Tabs.Root value={defaultValue}>
|
||||
<Tabs.List>
|
||||
{#if cname_record}
|
||||
<Tabs.Trigger value="cname">CNAME</Tabs.Trigger>
|
||||
{/if}
|
||||
{#if a_record || aaaa_record}
|
||||
<Tabs.Trigger value="a">A and AAAA</Tabs.Trigger>
|
||||
{/if}
|
||||
</Tabs.List>
|
||||
{#if cname_record}
|
||||
<Tabs.Content value="cname">
|
||||
<Card.Root>
|
||||
<Card.Header
|
||||
class="grid grid-cols-[80px_1fr_1fr] space-y-0 py-4">
|
||||
<Card.Title>Type</Card.Title>
|
||||
<Card.Title>Host</Card.Title>
|
||||
<Card.Title>Value</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Footer
|
||||
class="bg-muted grid grid-cols-[80px_1fr_1fr] py-4">
|
||||
<Card.Description>CNAME</Card.Description>
|
||||
<Card.Description>{host}</Card.Description>
|
||||
<Card.Description>{cname_record}</Card.Description>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
{/if}
|
||||
{#if a_record || aaaa_record}
|
||||
<Tabs.Content value="a">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if a_record}
|
||||
<Card.Root>
|
||||
<Card.Header
|
||||
class="grid grid-cols-[80px_1fr_1fr] space-y-0 py-4">
|
||||
<Card.Title>Type</Card.Title>
|
||||
<Card.Title>Host</Card.Title>
|
||||
<Card.Title>Value</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Footer
|
||||
class="bg-muted grid grid-cols-[80px_1fr_1fr] py-4">
|
||||
<Card.Description>A</Card.Description>
|
||||
<Card.Description>{host}</Card.Description>
|
||||
<Card.Description>{a_record}</Card.Description>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
{#if aaaa_record}
|
||||
<Card.Root>
|
||||
<Card.Header
|
||||
class="grid grid-cols-[80px_1fr_1fr] space-y-0 py-4">
|
||||
<Card.Title>Type</Card.Title>
|
||||
<Card.Title>Host</Card.Title>
|
||||
<Card.Title>Value</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Footer
|
||||
class="bg-muted grid grid-cols-[80px_1fr_1fr] py-4">
|
||||
<Card.Description>AAAA</Card.Description>
|
||||
<Card.Description>{host}</Card.Description>
|
||||
<Card.Description>{aaaa_record}</Card.Description>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
{/if}
|
||||
</Tabs.Root>
|
||||
@ -1,74 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js'
|
||||
import { InfoIcon } from 'lucide-svelte'
|
||||
|
||||
let {
|
||||
a_record,
|
||||
aaaa_record,
|
||||
cname_record,
|
||||
custom_ip,
|
||||
domain,
|
||||
}: {
|
||||
domain: string
|
||||
custom_ip: string | null
|
||||
cname_record: string
|
||||
a_record: string
|
||||
aaaa_record: string
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="flex items-center gap-1">
|
||||
<InfoIcon class="h-4 w-4" />
|
||||
{#if custom_ip}
|
||||
{custom_ip}
|
||||
{:else if cname_record}
|
||||
{cname_record}
|
||||
{:else if a_record}
|
||||
{a_record}
|
||||
{:else if aaaa_record}
|
||||
{aaaa_record}
|
||||
{:else}
|
||||
{'Public IP not found'}
|
||||
{/if}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{#if custom_ip}
|
||||
<div>
|
||||
{'Create a CNAME/ALIAS record for ' +
|
||||
domain +
|
||||
' to ' +
|
||||
custom_ip}
|
||||
</div>
|
||||
{/if}
|
||||
{#if cname_record}
|
||||
<div>
|
||||
{'Create a CNAME/ALIAS record for ' +
|
||||
domain +
|
||||
' to ' +
|
||||
cname_record}
|
||||
</div>
|
||||
{/if}
|
||||
{#if a_record}
|
||||
<div>
|
||||
{'Create a A record for ' + domain + ' to ' + a_record}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if aaaa_record}
|
||||
<div>
|
||||
{'Create a AAAA record for ' +
|
||||
domain +
|
||||
' to ' +
|
||||
aaaa_record}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !(custom_ip || cname_record || a_record || aaaa_record)}
|
||||
<div>
|
||||
{'Public IP not found'}
|
||||
</div>
|
||||
{/if}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue