feat: basic creation and deletion of urls

This commit is contained in:
Elias Renman
2025-03-24 10:16:37 +01:00
parent e789cf5a2c
commit 1f65d20d75
27 changed files with 446 additions and 26 deletions

View File

@@ -1,5 +1,18 @@
<script lang="ts">
import { QueryClientProvider } from "@tanstack/svelte-query";
import "./app.css";
import Home from "./pages/Home/Home.svelte";
import Login from "./pages/Login/Login.svelte";
import { queryClient } from "@/api";
const cookies = document.cookie;
</script>
<main></main>
<QueryClientProvider client={queryClient}>
<main class="p-4">
{#if cookies.includes("access_token")}
<Home />
{:else}
<Login />
{/if}
</main>
</QueryClientProvider>

3
web/src/api/client.ts Normal file
View File

@@ -0,0 +1,3 @@
import { QueryClient } from "@tanstack/svelte-query";
export const queryClient = new QueryClient();

2
web/src/api/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./client";
export * from "./url";

55
web/src/api/url.ts Normal file
View File

@@ -0,0 +1,55 @@
import { createMutation, createQuery } from "@tanstack/svelte-query";
import axios from "axios";
import { queryClient } from "./client";
export type UrlUpsertDto = {
url: string;
destinationUrl: string;
ttl: Date;
};
export type Url = {
url: string;
destinationUrl: string;
ttl: Date;
ownedBy: string;
createdAt: Date;
updatedAt: Date;
};
export function writeUrl({ url, ...rest }: UrlUpsertDto) {
return axios.patch(`/api/url/${url}`, rest, { withCredentials: true });
}
export function listUrls() {
return axios.get<Url[]>("/api/url/list", { withCredentials: true });
}
export function deleteUrl(url: string) {
return axios.delete(`/api/url/${url}`, { withCredentials: true });
}
export const queryUrls = () =>
createQuery({
queryKey: ["urls"],
queryFn: () => listUrls(),
});
export const mutateUrl = () =>
createMutation({
mutationFn: (data: UrlUpsertDto) => writeUrl(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["urls"],
});
},
});
export const mutateDeleteUrl = () =>
createMutation({
mutationFn: (data: string) => deleteUrl(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["urls"],
});
},
});

View File

@@ -1 +1,6 @@
@import "tailwindcss";
html {
background-color: black;
color: white;
}

View File

@@ -0,0 +1,41 @@
<script lang="ts">
export let name: keyof typeof icons;
export let width = "1rem";
export let height = "1rem";
export let focusable: string | number | null | undefined = undefined;
let icons = {
logout: {
box: 32,
svg: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15" />
</svg>`,
},
add: {
box: 32,
svg: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>`,
},
edit: {
box: 32,
svg: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg>`,
},
delete: {
box: 32,
svg: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>`,
},
} as const;
let displayIcon = icons[name];
</script>
<svg
class={$$props.class}
{focusable}
{width}
{height}
viewBox="0 0 {displayIcon.box} {displayIcon.box}">{@html displayIcon.svg}</svg
>

0
web/src/index.ts Normal file
View File

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import Create from "./components/create.svelte";
import Navbar from "./components/navbar.svelte";
import { queryUrls } from "@/api/url";
import Row from "./components/row.svelte";
const urls = queryUrls();
</script>
<Navbar />
<div class="h-screen flex items-center justify-center flex-col">
<table>
<thead>
<tr>
<th>Shortform</th>
<th>Destination</th>
<th>Time to live</th>
<th>Action</th>
</tr>
</thead>
{#if $urls.isLoading}
<tr>
<td><p>Loading...</p></td>
</tr>
{:else if $urls.isError}
<tr>
<td><p>Error: {$urls.error.message}</p></td>
</tr>
{:else if $urls.isSuccess}
{#each $urls.data.data as row}
<Row {row} />
{/each}
{/if}
</table>
<Create />
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { mutateUrl, type UrlUpsertDto } from "@/api/url";
import Icon from "@/common/Icon.svelte";
const mutation = mutateUrl();
const handleSubmit = (e: SubmitEvent) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
const formObject = Object.fromEntries(
data.entries(),
) as unknown as UrlUpsertDto;
console.log("Form Data:", formObject);
$mutation.mutate(formObject);
};
</script>
<form method="POST" on:submit={handleSubmit}>
<input name="url" type="text" class="border-b-1" />
<input name="destinationUrl" type="url" class="border-b-1" />
<button type="submit"><Icon name="add" /></button>
</form>

View File

@@ -0,0 +1,27 @@
import { jwtDecode, type JwtPayload } from "jwt-decode";
interface JwtExtendedPayload extends JwtPayload {
name: string;
}
function parseCookies(): Record<string, string> {
if (!document.cookie) return {};
return document.cookie.split("; ").reduce(
(prev, current) => {
const [name, ...value] = current.split("=");
prev[name] = value.join("=");
return prev;
},
{} as Record<string, string>,
);
}
export function decodeJwt(): JwtExtendedPayload | null {
const cookies = parseCookies();
if (Object.keys(cookies).length === 0 || !("access_token" in cookies)) {
return null;
}
return jwtDecode<JwtExtendedPayload>(cookies.access_token);
}

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import Icon from "@common/Icon.svelte";
import { decodeJwt } from "./jwt";
const payload = decodeJwt();
</script>
<div class="flex flexbox justify-between">
<p>{payload?.name}</p>
<a href="/logout">
<Icon name="logout" class="h-5 w-5" />
</a>
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { mutateDeleteUrl, type Url } from "@/api/url";
import Icon from "@/common/icon.svelte";
let { row }: { row: Url } = $props();
const mutation = mutateDeleteUrl();
function verifyDelete() {
if (confirm("Are you sure you want to delete this redirect?")) {
$mutation.mutate(row.url);
}
}
</script>
<tr class="border-b-1">
<td><a href={"/" + row.url} aria-label="Redirect url">{row.url}</a></td>
<td
><a href={row.destinationUrl} aria-label="Destination url"
>{row.destinationUrl}</a
></td
>
<td></td>
<td class="flex flex-row justify-around"
><Icon name="edit" />
<button onclick={verifyDelete}><Icon name="delete" /></button>
</td>
</tr>

View File

@@ -0,0 +1,38 @@
.github-signin-container {
background-color: #1b1f23;
transition: background-color 0.3s ease;
border-radius: 6px;
border: none;
}
.github-signin-btn {
display: flex;
align-items: center;
justify-content: center;
background-color: #1b1f23;
color: #fff;
font-size: 16px;
font-weight: 600;
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
text-decoration: none;
transition: background-color 0.3s ease;
width: 100%;
}
.github-signin-btn:hover {
background-color: #333;
}
.github-signin-btn .github-icon {
width: 25px;
height: 25px;
margin-right: 8px;
}
.github-signin-btn span {
font-family: Arial, sans-serif;
font-size: 16px;
}

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import "./Login.css";
</script>
<div class="h-screen flex items-center justify-center">
<div class="github-signin-container">
<a class="github-signin-btn" href="/login/github">
<img
src="https://cdn.pixabay.com/photo/2022/01/30/13/33/github-6980894_1280.png"
alt="GitHub Icon"
class="github-icon"
/>
Sign in with GitHub
</a>
</div>
</div>