Compare commits

...

2 commits

Author SHA1 Message Date
c3e6f481bb route: The image component 2026-03-16 07:06:38 +00:00
3ebeb8ed6a feat: Add the /admin pages 2026-03-16 07:04:44 +00:00
15 changed files with 388 additions and 24 deletions

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ node_modules
dev.db
local.db
uploads

View file

@ -0,0 +1,71 @@
<script lang="ts">
let canvasElement: HTMLCanvasElement;
let errorMessage = $state("");
let photoObjectUrl = $state("");
function handleFileChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) {
errorMessage = `No file selected, try again`;
return;
}
const ctx = canvasElement.getContext("2d");
if (!ctx) {
errorMessage = `Could not get 2d context from canvas`;
return;
}
ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
photoObjectUrl = URL.createObjectURL(file);
const image = new Image();
image.src = photoObjectUrl;
image.onload = () => {
const orientation = image.width > image.height ? "landscape" : "portrait";
if (orientation === "landscape") {
ctx.drawImage(image, 0, 0, canvasElement.width, canvasElement.height);
} else {
ctx.drawImage(image, 0, 0, canvasElement.height, canvasElement.width);
}
};
}
</script>
{#if errorMessage}
<div class="error-message">
{errorMessage}
</div>
{/if}
<form
method="POST"
action="/admin/photos"
enctype="multipart/form-data"
class="admin-form"
>
<canvas bind:this={canvasElement}></canvas>
<div class="field">
<label for="title">Title</label>
<input type="text" name="title" id="title" />
</div>
<div class="field">
<input
onchange={handleFileChange}
type="file"
name="file"
accept="image/*"
/>
</div>
<div class="field">
<label for="description">Description</label>
<textarea name="description" id="description"></textarea>
</div>
<button type="submit">Upload</button>
</form>

View file

@ -1,38 +1,45 @@
import type { Cookies } from "@sveltejs/kit";
export class CookieAuthentication {
private readonly cookieValue: string;
private readonly cookieValueArray: string[];
public static cookieName = 'auth'
public static adminAuthRole = 'admin'
public static cookieName = "auth";
public static adminAuthRole = "admin";
constructor(
private readonly cookies: Cookies,
){
this.cookieValue = this.cookies.get(CookieAuthentication.cookieName) ?? ''
this.cookieValueArray = this.cookieValue.split(',')
constructor(private readonly cookies: Cookies) {
this.cookieValue = this.cookies.get(CookieAuthentication.cookieName) ?? "";
this.cookieValueArray = this.cookieValue.split(",");
}
public get isAuthdAsAdmin(): boolean {
let isAuthdAsAdmin = false;
if (this.cookieValueArray.includes(CookieAuthentication.adminAuthRole)) {
isAuthdAsAdmin = true
isAuthdAsAdmin = true;
}
return isAuthdAsAdmin
return isAuthdAsAdmin;
}
public logout() {
if (!this.isAuthdAsAdmin) return;
this.cookies.delete(CookieAuthentication.cookieName, { path: "/" });
}
public setAdminAuthentication(isAuthd: boolean) {
let value = this.cookieValue
let value = this.cookieValue;
if (isAuthd) {
value = Array.from(new Set([...this.cookieValueArray, CookieAuthentication.adminAuthRole])).join(',')
value = Array.from(
new Set([...this.cookieValueArray, CookieAuthentication.adminAuthRole]),
).join(",");
} else {
value = this.cookieValueArray.filter((i) => i !== CookieAuthentication.adminAuthRole).join(',')
value = this.cookieValueArray
.filter((i) => i !== CookieAuthentication.adminAuthRole)
.join(",");
}
this.cookies.set(CookieAuthentication.cookieName, value, { path: '/'})
this.cookies.set(CookieAuthentication.cookieName, value, { path: "/" });
}
}

View file

@ -0,0 +1,58 @@
<script lang="ts">
import { page } from "$app/state";
const { children } = $props();
</script>
<div class="admin-layout">
{#if page.url.pathname !== "/admin/login"}
<nav class="navbar">
<menu class="left">
<li><a href="/admin/photos">Photos</a></li>
<li><a href="/admin/photos/upload">Upload a Photo</a></li>
<li><a href="/blog/new">Write a new blog post</a></li>
</menu>
<menu class="right">
<li>
<a href="/admin/logout">Log Out</a>
</li>
</menu>
</nav>
{/if}
{@render children()}
</div>
<style>
.admin-layout {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: min-content auto;
min-height: 100dvh;
}
.navbar {
width: 100dvw;
display: flex;
flex-direction: row;
}
.right {
display: flex;
justify-content: flex-end;
}
.navbar menu {
display: flex;
flex-direction: row;
gap: 4px;
flex-grow: 1;
}
.navbar menu li {
list-style: none;
}
li a::after {
content: "";
}
</style>

View file

@ -1 +1,3 @@
<h1>Welcome home, Wilson</h1>
<a href="/admin/photos">Upload Photo</a>

View file

@ -1,11 +1,39 @@
<h1>Admin login</h1>
<form method="POST">
<div class="container">
<form method="POST" class="form">
<div class="field">
<label for="token">API Token</label>
<label for="token">Password</label>
<input type="password" id="token" name="token" />
</div>
<div class="field">
<input type="submit" value="Login">
<input type="submit" value="Login" class="thomaswilson-button" />
</div>
</form>
</form>
</div>
<style>
.container {
display: grid;
place-content: center;
height: 100%;
}
.form {
display: grid;
grid-template-columns: 1fr;
width: 560px;
max-width: 90dvw;
gap: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label {
font-size: var(--font-size-lg);
}
</style>

View file

@ -0,0 +1,7 @@
import { CookieAuthentication } from "$lib/blog/auth/CookieAuthentication.js";
import { redirect, type ServerLoad } from "@sveltejs/kit";
export const GET: ServerLoad = ({ cookies }) => {
new CookieAuthentication(cookies).logout();
redirect(307, "/");
};

View file

@ -0,0 +1,47 @@
import { writeFile } from "node:fs/promises";
import { Buffer } from "node:buffer";
import { join } from "node:path";
import { PRIVATE_PHOTO_UPLOAD_DIR } from "$env/static/private";
import type { Actions, ServerLoad } from "@sveltejs/kit";
import { randomUUID } from "crypto";
export const load: ServerLoad = async ({ locals }) => {
const photos = await locals.prisma.photoPost.findMany({
select: {
id: true,
fileName: true,
title: true,
description: true,
createdAt: true,
},
});
return { photos };
};
export const actions = {
default: async ({ request, locals }) => {
const formData = await request.formData();
const file = formData.get("file") as File;
const title = formData.get("title") as string;
const description = formData.get("description") as string;
const filetype = file.type.split("/")[1];
const fileName = `${randomUUID()}.${filetype}`;
const fileLocation = join(PRIVATE_PHOTO_UPLOAD_DIR, fileName);
const fileContentBuffer = await file.arrayBuffer();
await writeFile(fileLocation, Buffer.from(fileContentBuffer));
await locals.prisma.photoPost.create({
data: {
fileName,
title,
description,
createdAt: new Date(),
},
});
return { success: true };
},
} satisfies Actions;

View file

@ -0,0 +1,12 @@
<script lang="ts">
import PhotoPostForm from "../../../components/admin/PhotoPostForm.svelte";
import type { PageProps } from "./$types.js";
import PhotoFeed from "./PhotoFeed.svelte";
const { data }: PageProps = $props();
</script>
<div>
<h1>Photo Posts</h1>
<PhotoFeed photos={data.photos} />
</div>

View file

View file

@ -0,0 +1,35 @@
<script lang="ts">
import type { PhotoPost } from "../../../../generated/prisma/client.js";
const { photos }: { photos: PhotoPost[] } = $props();
</script>
<ul class="photo-feed">
{#each photos as photo, id}
<li class="item">
<img width="250" src={`/image/${photo.fileName}`} alt={photo.title} />
<p>{photo.title}</p>
</li>
{/each}
</ul>
<style>
.photo-feed {
display: flex;
flex-wrap: wrap;
gap: 1rem;
list-style: none;
}
.item {
display: flex;
flex-direction: column;
justify-content: center;
}
.item img {
width: 250px;
height: auto;
height: fit-content;
}
</style>

View file

@ -0,0 +1,20 @@
<script>
import PhotoPostForm from "../../../../components/admin/PhotoPostForm.svelte";
</script>
<div class="form-container">
<h1>Upload a Photo</h1>
<PhotoPostForm />
</div>
<style>
.content {
display: grid;
place-items: center;
}
.form-container {
width: 100%;
max-width: 600px;
}
</style>

View file

@ -0,0 +1,25 @@
import { access, readFile } from "node:fs/promises";
import { constants } from "node:fs";
import { PRIVATE_PHOTO_UPLOAD_DIR } from "$env/static/private";
import * as path from "node:path";
import { error, type ServerLoad } from "@sveltejs/kit";
export const GET: ServerLoad = async ({ params, locals }) => {
const { filename } = params;
const proposedFilePath = path.join(PRIVATE_PHOTO_UPLOAD_DIR, filename);
const fileExists = await access(proposedFilePath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (!fileExists) {
return error(404, "File not found");
}
const file = await readFile(proposedFilePath);
const fileExt = path.extname(filename);
const contentType = `image/${fileExt.replace(".", "")}`;
return new Response(file, { headers: { "Content-Type": contentType } });
};

View file

@ -213,3 +213,50 @@ a.no-icon::after {
content: "";
display: none;
}
/* An Alert-like component*/
.alert {
--colour-scheme-border: var(--gray-800);
--font-size: var(--font-size-base);
--colour-scheme-text: var(--gray-800);
--colour-scheme-bg: var(--gray-100);
padding: 8px;
border: 1px solid var(--colour-scheme-border);
border-radius: 4px;
font-size: var(--font-size);
letter-spacing: 0px;
color: var(--colour-scheme-text);
background-color: var(--colour-scheme-bg);
}
.alert.error {
--colour-scheme-border: var(--red-800);
--colour-scheme-text: var(--red-800);
--colour-scheme-bg: var(--red-100);
}
.admin-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-form .field {
display: flex;
flex-direction: column;
gap: 4px;
}
.admin-form .field label {
font-size: var(--font-size);
letter-spacing: 0px;
color: var(--colour-scheme-text);
}
.admin-form .field input {
padding: 8px;
border: 1px solid var(--colour-scheme-border);
border-radius: 4px;
font-size: var(--font-size);
letter-spacing: 0px;
color: var(--colour-scheme-text);
background-color: var(--colour-scheme-bg);
}

View file

@ -10,7 +10,11 @@ const config = {
kit: {
adapter: adapter({ split: false }),
alias: {
$lib: "/src/lib",
$srcPrisma: "/src/prisma",
$generatedPrisma: "/generated/prisma/*",
},
env: {
publicPrefix: "PUBLIC_",
privatePrefix: "PRIVATE_",