added option to add custom domain to project in project settings page

main
TZGyn 1 year ago
parent 10485ac9a0
commit 9c1e72ed86
Signed by: TZGyn
GPG Key ID: 122EAF77AE81FD4A

@ -1,4 +1,14 @@
ORIGIN=http://localhost:3000
PROTOCOL_HEADER=x-forwarded-proto
HOST_HEADER=x-forwarded-host
DATABASE_URL=postgres://postgres:password@127.0.0.1:5432/link-shortener
PUBLIC_SHORTENER_URL=localhost:3000
APP_ENV=local
PUBLIC_SHORTENER_IP=1.1.1.1
PRIVATE_HOSTING_PROVIDER= # optional, (options: railway), if you don't have a hosting provider, leave it blank
# Railway config (if you have a Railway hosting provider)
PRIVATE_RAILWAY_API_KEY=
PRIVATE_RAILWAY_ENVIRONMENT_ID=
PRIVATE_RAILWAY_PROJECT_ID=
PRIVATE_RAILWAY_SERVICE_ID=

@ -5,6 +5,7 @@
"trailingComma": "all",
"printWidth": 70,
"bracketSameLine": true,
"htmlWhitespaceSensitivity": "ignore",
"plugins": [
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"

@ -0,0 +1,4 @@
ALTER TABLE "project" ADD COLUMN "domain_status" varchar(255) DEFAULT 'verified' NOT NULL;--> statement-breakpoint
ALTER TABLE "project" ADD COLUMN "enable_custom_domain" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "project" ADD COLUMN "custom_ip" varchar(255);--> statement-breakpoint
ALTER TABLE "project" ADD COLUMN "custom_domain_id" varchar(255);

@ -0,0 +1,367 @@
{
"id": "a4ffb6fc-ede8-431d-bd69-2429fbdc3ebd",
"prevId": "f5e2ea5c-7287-40ae-928a-3914f03045b5",
"version": "6",
"dialect": "postgresql",
"tables": {
"public.project": {
"name": "project",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"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": "integer",
"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
}
},
"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": "integer",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.setting": {
"name": "setting",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"qr_background": {
"name": "qr_background",
"type": "varchar(7)",
"primaryKey": false,
"notNull": false
},
"qr_foreground": {
"name": "qr_foreground",
"type": "varchar(7)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.shortener": {
"name": "shortener",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"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": "integer",
"primaryKey": false,
"notNull": true
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"project_id": {
"name": "project_id",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"shortener_code_unique": {
"name": "shortener_code_unique",
"nullsNotDistinct": false,
"columns": [
"code"
]
}
}
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"uuid": {
"name": "uuid",
"type": "uuid",
"primaryKey": false,
"notNull": false,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"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": "serial",
"primaryKey": true,
"notNull": true
},
"shortener_id": {
"name": "shortener_id",
"type": "integer",
"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": false
},
"device_vendor": {
"name": "device_vendor",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"os": {
"name": "os",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"browser": {
"name": "browser",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

@ -85,6 +85,13 @@
"when": 1718525942420,
"tag": "0011_striped_paper_doll",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1720469333262,
"tag": "0012_lame_vengeance",
"breakpoints": true
}
]
}

@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div class={cn("text-sm [&_p]:leading-relaxed", className)} {...$$restProps}>
<slot />
</div>

@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { HeadingLevel } from "./index.js";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLHeadingElement> & {
level?: HeadingLevel;
};
let className: $$Props["class"] = undefined;
export let level: $$Props["level"] = "h5";
export { className as class };
</script>
<svelte:element
this={level}
class={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...$$restProps}
>
<slot />
</svelte:element>

@ -0,0 +1,17 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { type Variant, alertVariants } from "./index.js";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement> & {
variant?: Variant;
};
let className: $$Props["class"] = undefined;
export let variant: $$Props["variant"] = "default";
export { className as class };
</script>
<div class={cn(alertVariants({ variant }), className)} {...$$restProps} role="alert">
<slot />
</div>

@ -0,0 +1,33 @@
import { type VariantProps, tv } from "tailwind-variants";
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export const alertVariants = tv({
base: "relative w-full rounded-lg border p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
});
export type Variant = VariantProps<typeof alertVariants>["variant"];
export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};

@ -36,6 +36,17 @@ export const project = pgTable('project', {
qr_foreground: varchar('qr_foreground', { length: 7 })
.default('#000000')
.notNull(),
domain_status: varchar('domain_status', {
length: 255,
})
.$type<'pending' | 'verified' | 'disabled'>()
.notNull()
.default('verified'),
enable_custom_domain: boolean('enable_custom_domain')
.notNull()
.default(false),
custom_ip: varchar('custom_ip', { length: 255 }),
custom_domain_id: varchar('custom_domain_id', { length: 255 }),
custom_domain: varchar('custom_domain', { length: 255 }),
})

@ -0,0 +1,54 @@
import { env } from '$env/dynamic/private'
export const checkDomainAvailable = async (domain: string) => {
if (env.PRIVATE_HOSTING_PROVIDER === 'railway') {
const { railwayClient } = await import('./railway')
const available = await railwayClient.checkDomain(domain)
return available
} else {
return true
}
}
export const createCustomDomain = async (domain: string) => {
if (env.PRIVATE_HOSTING_PROVIDER === 'railway') {
const { railwayClient } = await import('./railway')
const response = await railwayClient.createCustomDomain(domain)
if (!response.success) {
return { success: false, id: undefined, ip: undefined } as const
}
return {
success: true,
id: response.domain.id,
ip: response.domain.record,
} as const
} else {
return { id: undefined, ip: undefined } as const
}
}
export const deleteCustomDomain = async (domain: string | null) => {
if (!domain) {
return { success: true } as const
}
if (env.PRIVATE_HOSTING_PROVIDER === 'railway') {
const { railwayClient } = await import('./railway')
const response = await railwayClient.deleteCustomDomain(domain)
if (!response.success) {
return { success: false } as const
}
return {
success: true,
} as const
} else {
return { success: true } as const
}
}

@ -0,0 +1,119 @@
import { env } from '$env/dynamic/private'
class RailwayAPI {
constructor(private apiKey: string) {}
private sendRequest(query: string, variables: any) {
return fetch(`https://backboard.railway.app/graphql/v2`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({ query, variables }),
})
}
async checkDomain(domain: string) {
const query = `
query customDomainAvailable($domain: String!) {
customDomainAvailable(domain: $domain) {
__typename
available
message
}
}
`
const variables = {
domain: domain,
}
const response = await this.sendRequest(query, variables)
if (response.ok) {
const data: any = await response.json()
if (data.data.customDomainAvailable.available) {
return true
} else {
return false
}
} else {
return false
}
}
async createCustomDomain(domain: string) {
const query = `
mutation customDomainCreate($input: CustomDomainCreateInput!) {
customDomainCreate(input: $input) {
__typename
createdAt
deletedAt
domain
environmentId
id
projectId
serviceId
status {
dnsRecords {
requiredValue
}
}
updatedAt
}
}
`
const variables = {
input: {
domain: domain,
environmentId: env.PRIVATE_RAILWAY_ENVIRONMENT_ID,
projectId: env.PRIVATE_RAILWAY_PROJECT_ID,
serviceId: env.PRIVATE_RAILWAY_SERVICE_ID,
},
}
const response = await this.sendRequest(query, variables)
const data: any = await response.json()
if (!data.errors) {
console.log('Railway Create Domain', data)
return {
success: true,
domain: {
id: data.data.customDomainCreate.id as string,
record: data.data.customDomainCreate.status.dnsRecords[0]
.requiredValue as string,
},
} as const
} else {
return {
success: false,
message: 'Something went wrong',
} as const
}
}
async deleteCustomDomain(id: string) {
const query = `
mutation customDomainDelete($id: String!) {
customDomainDelete(id: $id)
}
`
const variables = {
id: id,
}
const response = await this.sendRequest(query, variables)
if (response.ok) {
const data: any = await response.json()
console.log('Railway Delete Domain', data)
return {
success: true,
} as const
} else {
return {
success: false,
} as const
}
}
}
export const railwayClient = new RailwayAPI(
env.PRIVATE_RAILWAY_API_KEY,
)

@ -7,7 +7,6 @@
import * as Avatar from '$lib/components/ui/avatar'
import { Badge } from '$lib/components/ui/badge'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import { Separator } from '$lib/components/ui/separator'
import type { Shortener, Project, Setting } from '$lib/db/types'
import {
BarChart,
@ -68,11 +67,15 @@
}
const hostDomain = 'https://' + shortener.link.split('/')[2]
const shortenerUrl = selected_project.enable_custom_domain
? selected_project.custom_domain || shortener_url
: shortener_url
</script>
<Card.Root>
<Card.Header>
<Card.Title class="flex gap-4 justify-between items-center">
<Card.Title class="flex items-center justify-between gap-4">
<Avatar.Root class="overflow-visible">
<Avatar.Image
src={hostDomain + '/favicon.ico'}
@ -81,11 +84,11 @@
<img src="/favicon.png" alt="favicon" />
</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col flex-grow gap-2 items-start">
<div class="flex flex-grow flex-col items-start gap-2">
<Tooltip.Root>
<Tooltip.Trigger>
<div
class="whitespace-nowrap max-w-[250px] overflow-x-clip overflow-ellipsis">
class="max-w-[250px] overflow-x-clip overflow-ellipsis whitespace-nowrap">
{shortener.link}
</div>
</Tooltip.Trigger>
@ -95,12 +98,12 @@
</Tooltip.Root>
<div
class="flex gap-2 items-center text-sm text-muted-foreground">
class="flex items-center gap-2 text-sm text-muted-foreground">
<a
href={'https://' + shortener_url + '/' + shortener.code}
href={'https://' + shortenerUrl + '/' + shortener.code}
target="_blank"
class="hover:underline">
{shortener_url + '/' + shortener.code}
{shortenerUrl + '/' + shortener.code}
</a>
<ExternalLink size={16} />
</div>
@ -112,13 +115,6 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<!-- <a -->
<!-- href={`/projects/${selected_project.uuid}/links/${shortener.code}/edit`} -->
<!-- on:click|preventDefault={showEditModal}> -->
<!-- <DropdownMenu.Item class="flex gap-2 items-center"> -->
<!-- <EditIcon size={16} />Edit -->
<!-- </DropdownMenu.Item> -->
<!-- </a> -->
<a
href={`/projects/${selected_project.uuid}/links/${shortener.code}/edit`}
on:click|preventDefault={() =>
@ -126,13 +122,13 @@
selected_project.uuid || '',
shortener.code,
)}>
<DropdownMenu.Item class="flex gap-2 items-center">
<DropdownMenu.Item class="flex items-center gap-2">
<EditIcon size={16} />Edit
</DropdownMenu.Item>
</a>
<DropdownMenu.Item
on:click={() => openDeleteDialog(shortener.code)}
class="flex gap-2 items-center text-destructive data-[highlighted]:bg-destructive">
class="flex items-center gap-2 text-destructive data-[highlighted]:bg-destructive">
<TrashIcon size={16} />
Delete
</DropdownMenu.Item>
@ -142,11 +138,11 @@
</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<div class="flex gap-2">
<Button
href={`/links/${shortener.code}`}
class="flex gap-1 justify-center items-center h-8 text-sm rounded bg-secondary">
class="flex h-8 items-center justify-center gap-1 rounded bg-secondary text-sm">
<BarChart size={20} />
<div>
{shortener.visitorCount} visits
@ -174,8 +170,9 @@
{#if shortener.android}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="outline" class="flex gap-2"
>Android</Badge>
<Badge variant="outline" class="flex gap-2">
Android
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{shortener.android_link}</p>
@ -190,13 +187,13 @@
<Badge variant="outline" class="flex gap-2">
{#if shortener.active}
<span
class="inline-flex relative w-2 h-2 bg-green-400 rounded-full"
></span>
class="relative inline-flex h-2 w-2 rounded-full bg-green-400">
</span>
Active
{:else}
<span
class="inline-flex relative w-2 h-2 bg-gray-600 rounded-full"
></span>
class="relative inline-flex h-2 w-2 rounded-full bg-gray-600">
</span>
Inactive
{/if}
</Badge>

@ -1,11 +1,24 @@
import { db } from '$lib/db'
import type { PageServerLoad, Actions } from './$types'
import { fail } from '@sveltejs/kit'
import { superValidate } from 'sveltekit-superforms'
import { formSchema, deleteSchema } from './schema'
import { setError, superValidate } from 'sveltekit-superforms'
import {
formSchema,
deleteSchema,
customDomainFormSchema,
} from './schema'
import { zod } from 'sveltekit-superforms/adapters'
import { project, shortener, visitor } from '$lib/db/schema'
import {
project as projectTable,
shortener,
visitor,
} from '$lib/db/schema'
import { and, eq } from 'drizzle-orm'
import {
checkDomainAvailable,
createCustomDomain,
deleteCustomDomain,
} from '$lib/server/domain'
export const load = (async (event) => {
const { project } = await event.parent()
@ -19,6 +32,10 @@ export const load = (async (event) => {
},
zod(formSchema),
),
customDomainForm: await superValidate(
{ domain: project.custom_domain || '' },
zod(customDomainFormSchema),
),
deleteForm: await superValidate(
{ deleteShorteners: true },
zod(deleteSchema),
@ -41,7 +58,7 @@ export const actions: Actions = {
const userId = event.locals.user.id
await db
.update(project)
.update(projectTable)
.set({
name: form.data.name,
qr_background: form.data.qr_background,
@ -49,8 +66,163 @@ export const actions: Actions = {
})
.where(
and(
eq(project.uuid, event.params.id),
eq(project.userId, userId),
eq(projectTable.uuid, event.params.id),
eq(projectTable.userId, userId),
),
)
return {
form,
}
},
enable_custom_domain: async (event) => {
const userId = event.locals.user.id
const existingProject = await db.query.project.findFirst({
where: (projectTable, { eq, and }) =>
and(
eq(projectTable.uuid, event.params.id),
eq(projectTable.userId, userId),
),
})
if (!existingProject) {
return fail(400, { message: 'Project not found' })
}
await db
.update(projectTable)
.set({
enable_custom_domain: true,
})
.where(
and(
eq(projectTable.uuid, event.params.id),
eq(projectTable.userId, userId),
),
)
return { message: 'Custom domain enabled' }
},
disable_custom_domain: async (event) => {
return { message: 'Disable custom domain is not available yet' }
const userId = event.locals.user.id
const existingProject = await db.query.project.findFirst({
where: (projectTable, { eq, and }) =>
and(
eq(projectTable.uuid, event.params.id),
eq(projectTable.userId, userId),
),
})
if (!existingProject) {
return fail(400, { message: 'Project not found' })
}
await db
.update(projectTable)
.set({
enable_custom_domain: false,
})
.where(
and(
eq(projectTable.uuid, event.params.id),
eq(projectTable.userId, userId),
),
)
return { message: 'Custom domain disabled' }
},
update_custom_domain: async (event) => {
const form = await superValidate(
event,
zod(customDomainFormSchema),
)
if (!form.valid) {
return fail(400, {
form,
})
}
const userId = event.locals.user.id
const existingProject = await db.query.project.findFirst({
where: (projectTable, { eq, and }) =>
and(
eq(projectTable.uuid, event.params.id),
eq(projectTable.userId, userId),
),
})
if (!existingProject || !existingProject.enable_custom_domain) {
return fail(400, {
form,
})
}
const sameDomainDifferentProject =
await db.query.project.findFirst({
where: (projectTable, { eq, and, ne }) =>
and(
ne(projectTable.uuid, event.params.id),
eq(projectTable.custom_domain, form.data.domain),
),
})
if (sameDomainDifferentProject) {
return setError(form, 'domain', 'Domain already taken')
}
const sameDomainSameProject = await db.query.project.findFirst({
where: (projectTable, { eq, and }) =>
and(
eq(projectTable.uuid, event.params.id),
eq(projectTable.custom_domain, form.data.domain),
),
})
if (sameDomainSameProject) {
return { form }
}
const domainAvailable = await checkDomainAvailable(
form.data.domain,
)
if (!domainAvailable) {
return setError(form, 'domain', 'Domain is not available')
}
const deleteOldCustomDomain = await deleteCustomDomain(
existingProject.custom_domain_id,
)
if (!deleteOldCustomDomain.success) {
return setError(
form,
'domain',
'Cannot delete old custom domain',
)
}
const customDomain = await createCustomDomain(form.data.domain)
if (!customDomain.success) {
return setError(form, 'domain', 'Cannot create custom domain')
}
await db
.update(projectTable)
.set({
custom_domain: form.data.domain,
custom_domain_id: customDomain.id,
custom_ip: customDomain.ip,
})
.where(
and(
eq(projectTable.uuid, event.params.id),
eq(projectTable.userId, userId),
),
)
@ -71,11 +243,11 @@ export const actions: Actions = {
try {
const deletedProject = await db
.delete(project)
.delete(projectTable)
.where(
and(
eq(project.uuid, event.params.id),
eq(project.userId, userId),
eq(projectTable.uuid, event.params.id),
eq(projectTable.userId, userId),
),
)
.returning()

@ -2,7 +2,7 @@
import { Separator } from '$lib/components/ui/separator'
import { Button } from '$lib/components/ui/button'
import * as Dialog from '$lib/components/ui/dialog'
import type { PageData } from './$types'
import * as Card from '$lib/components/ui/card'
import EditForm from './(components)/form.svelte'
import * as Form from '$lib/components/ui/form'
import { superForm } from 'sveltekit-superforms'
@ -11,8 +11,21 @@
import { Input } from '$lib/components/ui/input'
import { LoaderCircle } from 'lucide-svelte'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import * as Tooltip from '$lib/components/ui/tooltip'
import * as AlertDialog from '$lib/components/ui/alert-dialog'
import * as Alert from '$lib/components/ui/alert'
import {
InfoIcon,
CircleDashedIcon,
CircleXIcon,
CircleCheckBigIcon,
TriangleAlertIcon,
} from 'lucide-svelte'
import { PUBLIC_SHORTENER_URL } from '$env/static/public'
import { env } from '$env/dynamic/public'
import { invalidateAll } from '$app/navigation'
export let data: PageData
export let data
let deleteDialogOpen = false
@ -31,10 +44,214 @@
})
const { form: formData, enhance, submitting } = form
const customDomainForm = superForm(data.customDomainForm, {
invalidateAll: 'force',
resetForm: true,
onResult: ({ result }) => {
if (result.status === 200) {
toast.success('Custom domain updated')
}
if (result.status === 400) {
toast.error('Error updating custom domain')
}
},
})
const {
form: customDomainFormData,
enhance: customDomainEnhance,
submitting: customDomainSubmitting,
} = customDomainForm
</script>
<ScrollArea>
<div class="py-4 px-10 space-y-6 max-w-2xl">
<div class="max-w-2xl space-y-6 px-10 py-4">
<div>
<h3 class="text-lg font-medium">Custom Domain</h3>
<p class="text-sm text-muted-foreground">
Update project domain.
</p>
</div>
<Separator />
<Card.Root>
<Card.Header>
<div class="flex items-center gap-4">
<div class="">
{#if data.project.domain_status === 'pending'}
<CircleDashedIcon class="h-8 w-8 text-yellow-400" />
{:else if data.project.domain_status === 'verified'}
<CircleCheckBigIcon class="h-8 w-8 text-green-400" />
{:else if data.project.domain_status === 'disabled'}
<CircleXIcon class="h-8 w-8 text-red-400" />
{/if}
</div>
<div class="flex-grow">
{#if data.project.enable_custom_domain && data.project.custom_domain}
<Card.Title>
{data.project.custom_domain}
</Card.Title>
<Card.Description>custom domain</Card.Description>
<Card.Description>
<Tooltip.Root>
<Tooltip.Trigger class="flex items-center gap-1">
<InfoIcon class="h-4 w-4" />
{#if data.project.custom_ip}
{data.project.custom_ip}
{:else if env.PUBLIC_SHORTENER_IP}
{env.PUBLIC_SHORTENER_IP}
{:else}
{'Public IP not found'}
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
{#if data.project.custom_ip}
{'Create a CNAME record for ' +
data.project.custom_domain +
' to ' +
data.project.custom_ip}
{:else if env.PUBLIC_SHORTENER_IP}
{'Create a A record for ' +
data.project.custom_domain +
' to ' +
env.PUBLIC_SHORTENER_IP}
{:else}
{'Public IP not found'}
{/if}
</Tooltip.Content>
</Tooltip.Root>
</Card.Description>
{:else}
<Card.Title>
{PUBLIC_SHORTENER_URL}
</Card.Title>
<Card.Description>default domain</Card.Description>
{/if}
</div>
<div>
{#if !data.project.enable_custom_domain}
<AlertDialog.Root>
<AlertDialog.Trigger asChild let:builder>
<Button builders={[builder]}>
Enable Custom Domain
</Button>
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Are you absolutely sure?
</AlertDialog.Title>
<Alert.Root variant="destructive">
<TriangleAlertIcon class="h-4 w-4" />
<Alert.Description>
Disabling custom domain is not available yet.
</Alert.Description>
</Alert.Root>
<AlertDialog.Description>
Enabling a custom domain will allow you to use
your project with a custom domain.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
on:click={async () => {
await fetch('?/enable_custom_domain', {
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data',
},
})
await invalidateAll()
}}>
Continue
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{:else}
<!-- <AlertDialog.Root> -->
<!-- <AlertDialog.Trigger asChild let:builder> -->
<!-- <Button builders={[builder]}> -->
<!-- Disable Custom Domain -->
<!-- </Button> -->
<!-- </AlertDialog.Trigger> -->
<!-- <AlertDialog.Content> -->
<!-- <AlertDialog.Header> -->
<!-- <AlertDialog.Title> -->
<!-- Are you absolutely sure? -->
<!-- </AlertDialog.Title> -->
<!-- <AlertDialog.Description> -->
<!-- Your custom domain setting will remain. -->
<!-- </AlertDialog.Description> -->
<!-- </AlertDialog.Header> -->
<!-- <AlertDialog.Footer> -->
<!-- <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> -->
<!-- <AlertDialog.Action -->
<!-- on:click={async () => { -->
<!-- await fetch('?/disable_custom_domain', { -->
<!-- method: 'POST', -->
<!-- headers: { -->
<!-- 'Content-Type': 'multipart/form-data', -->
<!-- }, -->
<!-- }) -->
<!-- await invalidateAll() -->
<!-- }}> -->
<!-- Continue -->
<!-- </AlertDialog.Action> -->
<!-- </AlertDialog.Footer> -->
<!-- </AlertDialog.Content> -->
<!-- </AlertDialog.Root> -->
{/if}
</div>
</div>
</Card.Header>
</Card.Root>
{#if data.project.enable_custom_domain}
<form
method="POST"
action="?/update_custom_domain"
use:customDomainEnhance>
<Form.Field
form={customDomainForm}
name="domain"
class="flex flex-col gap-2">
<Form.Control let:attrs>
<Form.Label>Add Custom Domain</Form.Label>
<Input
{...attrs}
bind:value={$customDomainFormData.domain}
placeholder="your-custom-domain.com" />
</Form.Control>
<Form.Description
class="flex items-center justify-between gap-2">
<Tooltip.Root>
<Tooltip.Trigger class="flex items-center gap-2">
<InfoIcon class="h-4 w-4" />
Update Project Domain (leave blank to use default)
</Tooltip.Trigger>
<Tooltip.Content>
<p>Only include the domain name, not the protocol.</p>
<p>Make sure the domain is pointing to our server.</p>
<p>Please contact us if you need a custom domain.</p>
</Tooltip.Content>
</Tooltip.Root>
<Form.Button class="w-fit">
{#if $customDomainSubmitting}
<LoaderCircle class="animate-spin" />
{/if}
Update
</Form.Button>
</Form.Description>
<Form.FieldErrors />
</Form.Field>
</form>
{/if}
<div>
<h3 class="text-lg font-medium">Settings</h3>
<p class="text-sm text-muted-foreground">
@ -51,22 +268,18 @@
<h3 class="text-lg font-medium">Danger Zone</h3>
</div>
<div class="rounded-lg border border-destructive">
<div class="flex justify-between items-center p-4">
<div class="flex items-center justify-between p-4">
<div class="flex flex-col gap-1">
<span class="text-sm">Delete Project</span>
<span class="text-xs text-muted-foreground"
>Permanently delete your project</span>
<span class="text-xs text-muted-foreground">
Permanently delete your project
</span>
</div>
<Dialog.Root
open={deleteDialogOpen}
onOpenChange={(open) => (deleteDialogOpen = open)}>
<Dialog.Trigger>
<Button
variant="default"
class="group hover:bg-destructive">
<span class="text-destructive group-hover:text-primary"
>Delete Project</span>
</Button>
<Button variant="destructive">Delete Project</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
@ -88,7 +301,7 @@
{...attrs}
bind:value={$formData.deleteShorteners}
type="hidden" />
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<Checkbox
{...attrs}
id="deleteShorteners"
@ -100,11 +313,12 @@
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<div class="flex gap-2 justify-end">
<div class="flex justify-end gap-2">
<Button
variant="outline"
on:click={() => (deleteDialogOpen = false)}
>Cancel</Button>
on:click={() => (deleteDialogOpen = false)}>
Cancel
</Button>
<Form.Button variant="destructive" class="w-fit">
{#if $submitting}
<LoaderCircle class="animate-spin" />

@ -12,6 +12,10 @@ export const formSchema = z.object({
.max(7),
})
export const customDomainFormSchema = z.object({
domain: z.string(),
})
export type FormSchema = typeof formSchema
export const deleteSchema = z.object({

@ -34,10 +34,11 @@ app.get(
.selectAll('shortener')
.select(['project.custom_domain as domain'])
.where('shortener.code', '=', shortenerCode)
.where('project.custom_domain', '=', domain)
.orderBy('created_at', 'desc')
if (domain) {
query.where('project.custom_domain', '=', domain)
query.where('project.enable_custom_domain', '=', true)
}
const shortener = await query.execute()

@ -67,6 +67,7 @@ export interface ProjectTable {
name: string
userId: number
custom_domain: string | null
enable_custom_domain: boolean
}
export type Project = Selectable<ProjectTable>

Loading…
Cancel
Save