added projects

pull/3/head
TZGyn 2 years ago
parent 8a39b9b1df
commit f06d78b4a8
Signed by: TZGyn
GPG Key ID: 122EAF77AE81FD4A

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS "project" (
"id" serial PRIMARY KEY NOT NULL,
"uuid" uuid DEFAULT gen_random_uuid(),
"name" varchar(255) NOT NULL,
"user_id" integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE "shortener" ADD COLUMN "project_id" integer;

@ -0,0 +1,228 @@
{
"id": "9a3733db-b0ef-4744-b0bf-f69e9b03917c",
"prevId": "8f8ae49c-43bf-4e07-a30d-383e2b767524",
"version": "5",
"dialect": "pg",
"tables": {
"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
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"schema": "",
"columns": {
"token": {
"name": "token",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"shortener": {
"name": "shortener",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"link": {
"name": "link",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"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
},
"project_id": {
"name": "project_id",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"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"
]
}
}
},
"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
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

@ -22,6 +22,13 @@
"when": 1700882455122,
"tag": "0002_robust_the_executioner",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1701590526323,
"tag": "0003_glorious_norrin_radd",
"breakpoints": true
}
]
}

@ -17,6 +17,7 @@ export const shortener = pgTable('shortener', {
.defaultNow()
.notNull(),
userId: integer('user_id').notNull(),
projectId: integer('project_id'),
})
export const shortenerRelations = relations(
@ -26,10 +27,32 @@ export const shortenerRelations = relations(
fields: [shortener.userId],
references: [user.id],
}),
project: one(project, {
fields: [shortener.projectId],
references: [project.id],
}),
visitor: many(visitor),
}),
)
export const project = pgTable('project', {
id: serial('id').primaryKey().notNull(),
uuid: uuid('uuid').defaultRandom(),
name: varchar('name', { length: 255 }).notNull(),
userId: integer('user_id').notNull(),
})
export const projectRelations = relations(
project,
({ one, many }) => ({
user: one(user, {
fields: [project.userId],
references: [user.id],
}),
shortener: many(shortener),
}),
)
export const user = pgTable('user', {
id: serial('id').primaryKey().notNull(),
uuid: uuid('uuid').defaultRandom(),

@ -8,7 +8,8 @@ export const load = (async (event) => {
with: {
visitor: true,
},
where: (shortener, { eq }) => eq(shortener.userId, user.id),
where: (shortener, { eq, and, isNull }) =>
and(eq(shortener.userId, user.id), isNull(shortener.projectId)),
})
return { shorteners }

@ -0,0 +1,15 @@
import { db } from '$lib/db'
import type { PageServerLoad } from './$types'
export const load = (async (event) => {
const user = event.locals.userObject
const projects = await db.query.project.findMany({
with: {
shortener: true,
},
where: (project, { eq }) => eq(project.userId, user.id),
})
return { projects }
}) satisfies PageServerLoad

@ -0,0 +1,109 @@
<script lang="ts">
import type { PageData } from './$types'
import { Separator } from '$lib/components/ui/separator'
import { Button, buttonVariants } from '$lib/components/ui/button'
import * as Dialog from '$lib/components/ui/dialog'
import * as Card from '$lib/components/ui/card'
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
import { Input } from '$lib/components/ui/input'
import { Label } from '$lib/components/ui/label'
import {
ExternalLink,
Loader2,
MoreVertical,
PlusCircle,
} from 'lucide-svelte'
import { invalidateAll } from '$app/navigation'
export let data: PageData
let dialogOpen = false
let inputProjectName = ''
let isLoading = false
const addShortener = async () => {
isLoading = true
const response = await fetch('/api/project', {
method: 'post',
body: JSON.stringify({ name: inputProjectName }),
})
const responseData = await response.json()
isLoading = false
if (responseData.success) {
await invalidateAll()
dialogOpen = false
}
}
</script>
<div class="flex justify-between p-8">
<div class="text-4xl font-bold">Projects</div>
<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>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Name</Label>
<Input
id="name"
bind:value={inputProjectName}
class="col-span-3" />
</div>
</div>
<Dialog.Footer>
<Button on:click={addShortener} class="flex gap-2">
{#if isLoading}
<Loader2 class="animate-spin" />
{/if}
Add
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</div>
<Separator />
{#if data.projects.length > 0}
<div class="flex flex-col gap-4 overflow-scroll p-4">
{#each data.projects as project}
<a href={'/projects/' + project.uuid}>
<Card.Root
class="hover:bg-secondary w-full max-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>No Data</div>
{/if}

@ -0,0 +1,34 @@
import { db } from '$lib/db'
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
export const load = (async (event) => {
const uuid = event.params.uuid
const user = event.locals.userObject
try {
const project = await db.query.project.findFirst({
where: (project, { eq }) => eq(project.uuid, uuid),
})
if (!project) {
throw redirect(303, '/projects')
}
const shorteners = await db.query.shortener.findMany({
with: {
visitor: true,
},
where: (shortener, { eq, and }) =>
and(
eq(shortener.userId, user.id),
eq(shortener.projectId, project.id),
),
})
return { project, shorteners }
} catch (error) {
throw redirect(303, '/projects')
}
}) satisfies PageServerLoad

@ -0,0 +1,200 @@
<script lang="ts">
import type { PageData } from './$types'
import { Separator } from '$lib/components/ui/separator'
import { Button, buttonVariants } from '$lib/components/ui/button'
import * as Dialog from '$lib/components/ui/dialog'
import * as Card from '$lib/components/ui/card'
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
import { Input } from '$lib/components/ui/input'
import { Label } from '$lib/components/ui/label'
import {
BarChart,
ExternalLink,
Loader2,
MoreVertical,
PlusCircle,
} from 'lucide-svelte'
import { goto, invalidateAll } from '$app/navigation'
export let data: PageData
let dialogOpen = false
let inputLink = ''
let isLoading = false
const addShortener = async () => {
isLoading = true
const response = await fetch('/api/shortener', {
method: 'post',
body: JSON.stringify({ link: inputLink }),
})
const responseData = await response.json()
isLoading = false
if (responseData.success) {
await invalidateAll()
dialogOpen = false
}
}
let editDialogOpen = false
let editShortenerCode = ''
let editShortenerLink = ''
let isEditLoading = false
const openEditDialog = (code: string, link: string) => {
editDialogOpen = true
editShortenerCode = code
editShortenerLink = link
}
const editShortener = async (code: string, link: string) => {
isEditLoading = true
await fetch(`/api/shortener/${code}`, {
method: 'put',
body: JSON.stringify({
link,
}),
})
await invalidateAll()
isEditLoading = false
editDialogOpen = false
}
const deleteShortener = async (code: string) => {
await fetch(`/api/shortener/${code}`, {
method: 'delete',
})
await invalidateAll()
}
</script>
<div class="flex justify-between p-8">
<div class="text-4xl font-bold">Links</div>
<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-[425px]">
<Dialog.Header>
<Dialog.Title>Add Shortener</Dialog.Title>
<Dialog.Description>
Create A New Shortner Here. Click Add To Save.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Link</Label>
<Input
id="name"
bind:value={inputLink}
class="col-span-3" />
</div>
</div>
<Dialog.Footer>
<Button on:click={addShortener} class="flex gap-2">
{#if isLoading}
<Loader2 class="animate-spin" />
{/if}
Add
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</div>
<Separator />
{#if data.shorteners.length > 0}
<div class="flex flex-col gap-4 overflow-scroll p-4">
{#each data.shorteners as shortener}
<Card.Root class="w-full max-w-[500px]">
<Card.Header>
<Card.Title class="flex items-center gap-2">
<a
href={'https://' +
data.shortener_url +
'/' +
shortener.code}
target="_blank"
class="hover:underline">
{data.shortener_url + '/' + shortener.code}
</a>
<ExternalLink size={16} />
</Card.Title>
<Card.Description>{shortener.link}</Card.Description>
</Card.Header>
<Card.Content>
<div class="flex items-center justify-between">
<div class="flex gap-2">
<Button
class="bg-secondary flex h-8 items-center justify-center gap-1 rounded text-sm"
on:click={() => goto(`/links/${shortener.code}`)}>
<BarChart size={20} />
<div>
{shortener.visitor.length} visits
</div>
</Button>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<MoreVertical />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.Item
on:click={() =>
openEditDialog(shortener.code, shortener.link)}>
Edit
</DropdownMenu.Item>
<DropdownMenu.Item
on:click={() => deleteShortener(shortener.code)}
class="text-destructive data-[highlighted]:bg-destructive">
Delete
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
{:else}
<div>No Data</div>
{/if}
<Dialog.Root bind:open={editDialogOpen}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Edit Shortener {editShortenerCode}</Dialog.Title>
<Dialog.Description>
Edit Shortner Here. Click Save To Update.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">Link</Label>
<Input
id="name"
bind:value={editShortenerLink}
class="col-span-3" />
</div>
</div>
<Dialog.Footer>
<Button
on:click={() =>
editShortener(editShortenerCode, editShortenerLink)}
class="flex gap-2">
{#if isEditLoading}
<Loader2 class="animate-spin" />
{/if}
Save
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

@ -0,0 +1,36 @@
import { z } from 'zod'
import type { RequestHandler } from './$types'
import { db } from '$lib/db'
import { project } from '$lib/db/schema'
export const GET: RequestHandler = async () => {
return new Response()
}
const projectInsertSchema = z.object({
name: z.string(),
})
export const POST: RequestHandler = async (event) => {
const body = await event.request.json()
const projectInsert = projectInsertSchema.safeParse(body)
if (!projectInsert.success) {
return new Response(
JSON.stringify({
success: false,
message: 'Invalid Data',
}),
)
}
const user = event.locals.userObject
await db.insert(project).values({
name: projectInsert.data.name,
userId: user.id,
})
return new Response(JSON.stringify({ success: true }))
}
Loading…
Cancel
Save