mirror of
https://github.com/eliasrenman/url-shortener.git
synced 2026-03-17 04:26:05 +01:00
feat: basic creation and deletion of urls
This commit is contained in:
@@ -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
3
web/src/api/client.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { QueryClient } from "@tanstack/svelte-query";
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
2
web/src/api/index.ts
Normal file
2
web/src/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./client";
|
||||
export * from "./url";
|
||||
55
web/src/api/url.ts
Normal file
55
web/src/api/url.ts
Normal 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"],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
html {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
41
web/src/common/Icon.svelte
Normal file
41
web/src/common/Icon.svelte
Normal 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
0
web/src/index.ts
Normal file
36
web/src/pages/Home/Home.svelte
Normal file
36
web/src/pages/Home/Home.svelte
Normal 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>
|
||||
24
web/src/pages/Home/components/create.svelte
Normal file
24
web/src/pages/Home/components/create.svelte
Normal 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>
|
||||
27
web/src/pages/Home/components/jwt.ts
Normal file
27
web/src/pages/Home/components/jwt.ts
Normal 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);
|
||||
}
|
||||
13
web/src/pages/Home/components/navbar.svelte
Normal file
13
web/src/pages/Home/components/navbar.svelte
Normal 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>
|
||||
26
web/src/pages/Home/components/row.svelte
Normal file
26
web/src/pages/Home/components/row.svelte
Normal 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>
|
||||
38
web/src/pages/Login/Login.css
Normal file
38
web/src/pages/Login/Login.css
Normal 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;
|
||||
}
|
||||
16
web/src/pages/Login/Login.svelte
Normal file
16
web/src/pages/Login/Login.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user