file upload (currently tigris) + migrate old dashboard to new one

main
TZGyn 12 months ago
parent 968e1568e2
commit 7eae6141c3
Signed by: TZGyn
GPG Key ID: 122EAF77AE81FD4A

@ -32,4 +32,13 @@ PRIVATE_GOOGLE_CLIENT_SECRET=
# Stripe
PRIVATE_STRIPE_SECRET_KEY=
PRIVATE_STRIPE_WEBHOOK_SECRET=
PRIVATE_PRO_PLAN_PRICE_ID=
PRIVATE_PRO_PLAN_PRICE_ID=
# Tigris/S3
PRIVATE_AWS_WEBHOOK_TOKEN=
PRIVATE_AWS_BUCKET_NAME=
PRIVATE_AWS_ACCESS_KEY_ID=
PRIVATE_AWS_SECRET_ACCESS_KEY=
PRIVATE_AWS_ENDPOINT_URL_S3=
PRIVATE_AWS_ENDPOINT_URL_IAM=
PRIVATE_AWS_REGION=auto

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": {}
}
}

@ -197,6 +197,13 @@
"when": 1726769139213,
"tag": "0027_funny_wasp",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1734847628427,
"tag": "0028_hard_red_shift",
"breakpoints": true
}
]
}

@ -40,6 +40,8 @@
},
"type": "module",
"dependencies": {
"@aws-sdk/client-s3": "^3.705.0",
"@aws-sdk/s3-request-presigner": "^3.705.0",
"@lucia-auth/adapter-drizzle": "^1.0.7",
"@prgm/sveltekit-progress-bar": "^2.0.0",
"@stripe/stripe-js": "^4.3.0",
@ -62,6 +64,7 @@
"qr-code-styling": "^1.6.0-rc.1",
"resend": "^3.4.0",
"stripe": "^16.8.0",
"svelte-file-dropzone": "^2.0.9",
"svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.20.0",
"tailwind-merge": "^2.5.4",

@ -99,7 +99,7 @@
</Sidebar.Content>
<Sidebar.Footer>
{#if sidebar.open}
<Button href="/dashboard-new" variant="outline">
<Button href="/dashboard" variant="outline">
<SparkleIcon class="text-brand" />
Try New Dashboard
</Button>

@ -97,7 +97,7 @@
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Group>
<a href="/dashboard/settings/account">
<a href="/dashboard/account/settings">
<DropdownMenu.Item>
<BadgeCheck />
Account

@ -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}

@ -6,6 +6,9 @@ import {
uuid,
boolean,
text,
integer,
numeric,
bigint,
} from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'
@ -40,6 +43,11 @@ export const user = pgTable('user', {
.notNull()
.default('square'),
qrImageBase64: text('qr_image_base64'),
fileStorageUsageInByte: bigint('file_storage_usage_in_byte', {
mode: 'number',
})
.notNull()
.default(0),
})
export const shortener = pgTable('shortener', {
@ -56,6 +64,24 @@ export const shortener = pgTable('shortener', {
userId: text('user_id').notNull(),
active: boolean('active').notNull().default(true),
projectId: text('project_id'),
is_file_upload: boolean('is_file_upload').notNull().default(false),
file_path: text('file_path'),
})
export const file = pgTable('file', {
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
projectId: text('project_id'),
key: text('key').notNull(),
name: text('name').notNull(),
size: bigint('size', {
mode: 'number',
})
.notNull()
.default(0),
eTag: text('etag').notNull(),
createdAt: bigint('created_at_epoch', { mode: 'number' }).notNull(),
updatedAt: bigint('updated_at_epoch', { mode: 'number' }).notNull(),
})
export const project = pgTable('project', {
@ -180,3 +206,10 @@ export const sessionRelations = relations(session, ({ one }) => ({
references: [user.id],
}),
}))
export const fileRelations = relations(file, ({ one }) => ({
shortener: one(shortener, {
fields: [file.key],
references: [shortener.file_path],
}),
}))

@ -10,3 +10,19 @@ export const userCreateSchema = z.object({
password: z.string(),
password_confirm: z.string(),
})
export type TigrisNotificationPayload = {
events: {
eventVersion: string
eventSource: string
eventName: 'OBJECT_CREATED_PUT' | 'OBJECT_DELETED'
eventTime: string
bucket: string
object: {
key: string
size: number
eTag: string
}
}[]
sendTime: string
}

@ -91,3 +91,32 @@ Number.prototype.toDecimalPoint = function (decimal: number) {
export const isAlphanumeric = (str: string) => {
return str.match('^[A-Za-z0-9]+$')
}
// https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
export function byteToHumanReadable(
bytes: number,
si = true,
dp = 1,
) {
const thresh = si ? 1000 : 1024
if (Math.abs(bytes) < thresh) {
return bytes + ' B'
}
const units = si
? ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
let u = -1
const r = 10 ** dp
do {
bytes /= thresh
++u
} while (
Math.round(Math.abs(bytes) * r) / r >= thresh &&
u < units.length - 1
)
return bytes.toFixed(dp) + ' ' + units[u]
}

@ -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,30 +1,8 @@
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad, Actions } from './$types'
import { db } from '$lib/db'
import { sql } from 'drizzle-orm'
export const load = (async (event) => {
const user = event.locals.user
const projects = await db.query.project.findMany({
with: {
shortener: {
with: {
visitor: true,
},
},
},
where: (project, { eq }) => eq(project.userId, user.id),
})
const shorteners = await db.query.shortener.findMany({
with: {
visitor: true,
},
where: (shortener, { eq }) => eq(shortener.userId, user.id),
})
return { projects, shorteners }
redirect(302, '/dashboard/project/personal')
}) satisfies PageServerLoad
export const actions = {

@ -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>

@ -6,15 +6,15 @@
const items = [
{
title: 'Account',
href: '/dashboard-new/account/settings/account',
href: '/dashboard/account/settings/account',
},
{
title: 'QR',
href: '/dashboard-new/account/settings/qr',
href: '/dashboard/account/settings/qr',
},
{
title: 'Security',
href: '/dashboard-new/account/settings/security',
href: '/dashboard/account/settings/security',
},
] as const
</script>

@ -11,7 +11,7 @@
<Button
variant="outline"
class="h-auto px-2"
href="/dashboard-new/project/personal">
href="/dashboard/project/personal">
<ArrowLeftIcon />
</Button>
<div class="space-y-0.5">

@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
export const load = (async () => {
redirect(300, '/dashboard/settings/account')
redirect(300, '/dashboard/account/settings/account')
}) satisfies PageServerLoad

@ -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

@ -8,7 +8,7 @@ import { zod } from 'sveltekit-superforms/adapters'
import { formSchema } from './[project_id]/(components)/schema'
export const load = (async (event) => {
redirect(302, '/dashboard-new/project/personal')
redirect(302, '/dashboard/project/personal')
}) satisfies PageServerLoad
export const actions: Actions = {

@ -7,7 +7,14 @@
import ProjectSwitcher from './project-switcher.svelte'
import * as Sidebar from '$lib/components/ui/sidebar/index.js'
import type { ComponentProps } from 'svelte'
import { HomeIcon, LinkIcon, SettingsIcon } from 'lucide-svelte'
import {
CloudDownloadIcon,
CloudIcon,
FileIcon,
HomeIcon,
LinkIcon,
SettingsIcon,
} from 'lucide-svelte'
import { page } from '$app/stores'
import type { User } from 'lucia'
import type { Project } from '$lib/db/types'
@ -16,6 +23,9 @@
type SuperValidated,
type Infer,
} from 'sveltekit-superforms'
import { Progress } from '$lib/components/ui/progress'
import { byteToHumanReadable, cn } from '$lib/utils'
import { Button } from '$lib/components/ui/button'
let {
ref = $bindable(null),
@ -32,6 +42,8 @@
createProjectForm: SuperValidated<Infer<FormSchema>>
} = $props()
const sidebar = Sidebar.useSidebar()
let projectId = $derived(activeProject?.id || 'personal')
let data = $derived({
@ -49,30 +61,41 @@
navMain: [
{
title: 'Overview',
url: `/dashboard-new/project/${projectId}`,
url: `/dashboard/project/${projectId}`,
icon: HomeIcon,
isActive:
$page.url.pathname ===
`/dashboard-new/project/${projectId}`,
$page.url.pathname === `/dashboard/project/${projectId}`,
isUnlocked: true,
},
{
title: 'Links',
url: `/dashboard-new/project/${projectId}/links`,
url: `/dashboard/project/${projectId}/links`,
icon: LinkIcon,
isActive: $page.url.pathname.startsWith(
`/dashboard-new/project/${projectId}/links`,
`/dashboard/project/${projectId}/links`,
),
isUnlocked: true,
},
{
title: 'Files',
url: `/dashboard/project/${projectId}/file_uploads`,
icon: FileIcon,
isActive: $page.url.pathname.startsWith(
`/dashboard/project/${projectId}/file_uploads`,
),
isUnlocked: user.plan !== 'free',
},
{
title: 'Settings',
url:
projectId === 'personal'
? `/dashboard-new/account/settings`
: `/dashboard-new/project/${projectId}/settings`,
? `/dashboard/account/settings`
: `/dashboard/project/${projectId}/settings`,
icon: SettingsIcon,
isActive: $page.url.pathname.startsWith(
`/dashboard-new/project/${projectId}/settings`,
`/dashboard/project/${projectId}/settings`,
),
isUnlocked: true,
},
],
})
@ -95,12 +118,27 @@
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={item.isActive}>
{#snippet child({ props })}
<a href={item.url} {...props}>
{#if item.icon}
<item.icon class="h-8" />
{/if}
<span>{item.title}</span>
</a>
{#if item.isUnlocked}
<a href={item.url} {...props}>
{#if item.icon}
<item.icon class="h-8" />
{/if}
<span>{item.title}</span>
</a>
{:else}
<span
{...props}
class={cn(
props.class as string,
'text-muted-foreground select-none hover:cursor-pointer',
)}>
{#if item.icon}
<item.icon class="h-8" />
{/if}
<span>{item.title}</span>
<span class="text-brand">(Pro)</span>
</span>
{/if}
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
@ -109,6 +147,23 @@
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
{#if sidebar.open && user.plan !== 'free'}
<div class="flex flex-col gap-4 rounded-lg border p-4 text-sm">
<div class="flex items-center gap-4">
<CloudIcon />
<span>Storage Usage</span>
</div>
<div class="flex flex-col gap-2">
<span class="text-muted-foreground">
{byteToHumanReadable(user.fileStorageUsageInByte)}
/ 100 GB
</span>
<Progress
max={100_000_000_000}
value={user.fileStorageUsageInByte} />
</div>
</div>
{/if}
<NavUser user={data.user} />
</Sidebar.Footer>
<Sidebar.Rail />

@ -80,7 +80,7 @@
<DropdownMenu.Label class="text-muted-foreground text-xs">
Default
</DropdownMenu.Label>
<a href={`/dashboard-new/project/personal`}>
<a href={`/dashboard/project/personal`}>
<DropdownMenu.Item class="gap-2 p-2">
<div
class="size-6 flex items-center justify-center rounded-sm border">
@ -97,7 +97,7 @@
Projects
</DropdownMenu.Label>
{#each projects as project, index (project.name)}
<a href={`/dashboard-new/project/${project.id}`}>
<a href={`/dashboard/project/${project.id}`}>
<DropdownMenu.Item class="gap-2 p-2">
<div
class="size-6 flex items-center justify-center rounded-sm border">
@ -135,7 +135,7 @@
Create A New Project Here. Click Add To Create.
</Dialog.Description>
</Dialog.Header>
<form method="POST" use:enhance action="/dashboard-new/project">
<form method="POST" use:enhance action="/dashboard/project">
<Form.Field {form} name="name">
<Form.Control>
{#snippet children({ props })}

@ -29,8 +29,8 @@ export const load = (async (event) => {
{
name: activeProject?.name || 'Personal',
path: activeProject
? `/dashboard-new/project/${activeProject.id}`
: '/dashboard-new/project/personal',
? `/dashboard/project/${activeProject.id}`
: '/dashboard/project/personal',
},
]

@ -13,7 +13,7 @@
<ChartAreaIcon />
</div>
<Button
href={`/dashboard-new/project/${data.activeProjectId}/links`}>
href={`/dashboard/project/${data.activeProjectId}/links`}>
View Details
</Button>
</Card.Header>

@ -11,7 +11,7 @@
<div class="text-4xl font-bold">Page Not Found</div>
</div>
<Button
href={`/dashboard-new/project/${data.activeProjectId}`}
href={`/dashboard/project/${data.activeProjectId}`}
class="w-fit">
Return Home
</Button>

@ -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>

@ -18,6 +18,8 @@
QrCode,
TrashIcon,
Loader2Icon,
FileIcon,
Split,
} from 'lucide-svelte'
import DeleteShortenerDialog from './DeleteShortenerDialog.svelte'
import ProjectEditLinkPage from '../[linkid]/edit/+page.svelte'
@ -47,7 +49,7 @@
}
const getUrl = () => {
return `/dashboard-new/project/${activeProjectId}`
return `/dashboard/project/${activeProjectId}`
}
let editProjectLinkOpen = $state(false)
@ -57,7 +59,7 @@
const showEditModal = async () => {
isLoadingEditProjectData = true
editProjectLinkOpen = true
const href = `/dashboard-new/project/${activeProjectId}/links/${shortener.id}/edit`
const href = `/dashboard/project/${activeProjectId}/links/${shortener.id}/edit`
const result = await preloadData(href)
if (result.type === 'loaded' && result.status === 200) {
@ -94,16 +96,20 @@
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center justify-between gap-4">
<Avatar.Root class="overflow-visible">
<Avatar.Image
src={'https://www.google.com/s2/favicons?sz=128&domain_url=' +
shortener.link}
alt="favicon"
class="h-10 w-10" />
<Avatar.Fallback class="h-10 w-10 bg-opacity-0">
<img src="/favicon.png" alt="favicon" />
</Avatar.Fallback>
</Avatar.Root>
{#if shortener.is_file_upload}
<FileIcon class="h-10 w-10" />
{:else}
<Avatar.Root class="overflow-visible">
<Avatar.Image
src={'https://www.google.com/s2/favicons?sz=128&domain_url=' +
shortener.link}
alt="favicon"
class="h-10 w-10" />
<Avatar.Fallback class="h-10 w-10 bg-opacity-0">
<img src="/favicon.png" alt="favicon" />
</Avatar.Fallback>
</Avatar.Root>
{/if}
<div class="flex flex-grow flex-col items-start gap-2">
<Tooltip.Provider>
@ -111,11 +117,17 @@
<Tooltip.Trigger>
<div
class="max-w-[250px] overflow-x-clip overflow-ellipsis whitespace-nowrap">
{shortener.link}
{shortener.is_file_upload
? shortener.file_path?.split('/').pop()
: shortener.link}
</div>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{shortener.link}</p>
<p>
{shortener.is_file_upload
? shortener.file_path?.split('/').pop()
: shortener.link}
</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
@ -163,7 +175,7 @@
<div class="flex items-center justify-between">
<div class="flex gap-2">
<Button
href={`/dashboard/links/${shortener.id}`}
href={`/dashboard/project/${activeProjectId}/links/${shortener.id}`}
class="bg-secondary flex h-8 items-center justify-center gap-1 rounded text-sm">
<BarChart size={20} />
<div>

@ -11,7 +11,7 @@ export const load = (async (event) => {
...parentBreadcrumbs,
{
name: 'Links',
path: `/dashboard-new/project/${activeProjectId}/links`,
path: `/dashboard/project/${activeProjectId}/links`,
},
]

@ -6,7 +6,7 @@ import { visitor as visitorSchema } from '$lib/db/schema'
export const load = (async (event) => {
const user = event.locals.user
const shortenerId = event.params.id
const shortenerId = event.params.linkid
const shortener = await db.query.shortener.findFirst({
where: (shortener, { eq, and }) =>

@ -89,7 +89,7 @@
method="POST"
use:enhance
class="flex flex-col gap-6"
action={`/dashboard-new/project/${projectId}/links/${linkId}/edit`}>
action={`/dashboard/project/${projectId}/links/${linkId}/edit`}>
<Form.Field {form} name="link" class="flex flex-col gap-2">
<Form.Control>
{#snippet children({ props })}

@ -37,13 +37,13 @@ export const load = (async (event) => {
activeProjectName,
} = await event.parent()
if (!project) redirect(302, '/dashboard-new/project')
if (!project) redirect(302, '/dashboard/project')
const breadcrumbs = [
...parentBreadcrumbs,
{
name: 'Settings',
path: `/dashboard-new/project/${activeProjectId}/settings`,
path: `/dashboard/project/${activeProjectId}/settings`,
},
]

@ -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…
Cancel
Save