diff --git a/frontend/.env.example b/frontend/.env.example
index d1001df..b366ae3 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -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=
diff --git a/frontend/.prettierrc b/frontend/.prettierrc
index 7e9a1e9..7fc6612 100644
--- a/frontend/.prettierrc
+++ b/frontend/.prettierrc
@@ -5,6 +5,7 @@
"trailingComma": "all",
"printWidth": 70,
"bracketSameLine": true,
+ "htmlWhitespaceSensitivity": "ignore",
"plugins": [
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"
diff --git a/frontend/drizzle/0012_lame_vengeance.sql b/frontend/drizzle/0012_lame_vengeance.sql
new file mode 100644
index 0000000..1242111
--- /dev/null
+++ b/frontend/drizzle/0012_lame_vengeance.sql
@@ -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);
\ No newline at end of file
diff --git a/frontend/drizzle/meta/0012_snapshot.json b/frontend/drizzle/meta/0012_snapshot.json
new file mode 100644
index 0000000..2943375
--- /dev/null
+++ b/frontend/drizzle/meta/0012_snapshot.json
@@ -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": {}
+ }
+}
\ No newline at end of file
diff --git a/frontend/drizzle/meta/_journal.json b/frontend/drizzle/meta/_journal.json
index e0546ab..2125cc4 100644
--- a/frontend/drizzle/meta/_journal.json
+++ b/frontend/drizzle/meta/_journal.json
@@ -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
}
]
}
\ No newline at end of file
diff --git a/frontend/src/lib/components/ui/alert/alert-description.svelte b/frontend/src/lib/components/ui/alert/alert-description.svelte
new file mode 100644
index 0000000..5bbc85a
--- /dev/null
+++ b/frontend/src/lib/components/ui/alert/alert-description.svelte
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/ui/alert/alert-title.svelte b/frontend/src/lib/components/ui/alert/alert-title.svelte
new file mode 100644
index 0000000..2795342
--- /dev/null
+++ b/frontend/src/lib/components/ui/alert/alert-title.svelte
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/ui/alert/alert.svelte b/frontend/src/lib/components/ui/alert/alert.svelte
new file mode 100644
index 0000000..e1aa6df
--- /dev/null
+++ b/frontend/src/lib/components/ui/alert/alert.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/ui/alert/index.ts b/frontend/src/lib/components/ui/alert/index.ts
new file mode 100644
index 0000000..c21bc1e
--- /dev/null
+++ b/frontend/src/lib/components/ui/alert/index.ts
@@ -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["variant"];
+export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
+
+export {
+ Root,
+ Description,
+ Title,
+ //
+ Root as Alert,
+ Description as AlertDescription,
+ Title as AlertTitle,
+};
diff --git a/frontend/src/lib/db/schema.ts b/frontend/src/lib/db/schema.ts
index 22d3859..c4cb84e 100644
--- a/frontend/src/lib/db/schema.ts
+++ b/frontend/src/lib/db/schema.ts
@@ -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 }),
})
diff --git a/frontend/src/lib/server/domain.ts b/frontend/src/lib/server/domain.ts
new file mode 100644
index 0000000..3d94d00
--- /dev/null
+++ b/frontend/src/lib/server/domain.ts
@@ -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
+ }
+}
diff --git a/frontend/src/lib/server/railway.ts b/frontend/src/lib/server/railway.ts
new file mode 100644
index 0000000..b0a5a1b
--- /dev/null
+++ b/frontend/src/lib/server/railway.ts
@@ -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,
+)
diff --git a/frontend/src/routes/(app)/projects/[id]/(components)/ShortenerCard.svelte b/frontend/src/routes/(app)/projects/[id]/(components)/ShortenerCard.svelte
index 62e06f6..188ca17 100644
--- a/frontend/src/routes/(app)/projects/[id]/(components)/ShortenerCard.svelte
+++ b/frontend/src/routes/(app)/projects/[id]/(components)/ShortenerCard.svelte
@@ -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
-
+
-
+
+ class="max-w-[250px] overflow-x-clip overflow-ellipsis whitespace-nowrap">
{shortener.link}
@@ -95,12 +98,12 @@
@@ -112,13 +115,6 @@
-
-
-
-
-
-
-
@@ -126,13 +122,13 @@
selected_project.uuid || '',
shortener.code,
)}>
-
+
Edit
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">
Delete
@@ -142,11 +138,11 @@
-
+