items revisions
All checks were successful
ci / docker_image (push) Successful in 2m56s
ci / deploy (push) Successful in 26s

This commit is contained in:
Drake Marino 2026-02-04 01:30:46 -06:00
parent 4ea6549ac7
commit 8ea632a14f
25 changed files with 621 additions and 280 deletions

17
package-lock.json generated
View File

@ -17,7 +17,8 @@
"nodemailer": "^7.0.13", "nodemailer": "^7.0.13",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"svelte-preprocess": "^6.0.3" "svelte-preprocess": "^6.0.3",
"valibot": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.4.0", "@eslint/compat": "^1.4.0",
@ -6646,6 +6647,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/valibot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
"integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==",
"license": "MIT",
"peerDependencies": {
"typescript": ">=5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@ -62,6 +62,7 @@
"nodemailer": "^7.0.13", "nodemailer": "^7.0.13",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"svelte-preprocess": "^6.0.3" "svelte-preprocess": "^6.0.3",
"valibot": "^1.2.0"
} }
} }

View File

@ -39,6 +39,7 @@
--sidebar-ring: oklch(0.606 0.25 292.717); --sidebar-ring: oklch(0.606 0.25 292.717);
--warning: oklch(0.84 0.16 84); --warning: oklch(0.84 0.16 84);
--error: oklch(0.577 0.245 27.325); --error: oklch(0.577 0.245 27.325);
--positive: oklch(0.5 0.2067 147.18);
} }
.dark { .dark {
@ -75,6 +76,7 @@
--sidebar-ring: oklch(0.541 0.281 293.009); --sidebar-ring: oklch(0.541 0.281 293.009);
--warning: oklch(0.84 0.16 84); --warning: oklch(0.84 0.16 84);
--error: oklch(0.704 0.191 22.216); --error: oklch(0.704 0.191 22.216);
--positive: oklch(0.7522 0.2067 147.18);
} }
@theme inline { @theme inline {
@ -115,6 +117,7 @@
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-warning: var(--warning); --color-warning: var(--warning);
--color-error: var(--error); --color-error: var(--error);
--color-positive: var(--positive);
} }
@layer base { @layer base {

View File

@ -1,10 +1,10 @@
import type { User } from '$lib/types/user'; import type { User, UserPayload } from '$lib/types/user';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import sql from '$lib/db/db.server'; import sql from '$lib/db/db.server';
import type { Cookies } from '@sveltejs/kit'; import { type Cookies, error } from '@sveltejs/kit';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
export function setJWT(cookies: Cookies, user: User) { export function setJWTCookie(cookies: Cookies, user: User) {
const payload = { const payload = {
id: user.id, id: user.id,
email: user.email, email: user.email,
@ -12,10 +12,10 @@ export function setJWT(cookies: Cookies, user: User) {
}; };
if (process.env.JWT_SECRET === undefined) { if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined'); throw Error('JWT_SECRET not defined');
} }
if (process.env.BASE_URL === undefined) { if (process.env.BASE_URL === undefined) {
throw new Error('BASE_URL not defined'); throw Error('BASE_URL not defined');
} }
const secure: boolean = process.env.BASE_URL?.includes('https://'); const secure: boolean = process.env.BASE_URL?.includes('https://');
@ -25,17 +25,14 @@ export function setJWT(cookies: Cookies, user: User) {
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false, secure }); cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false, secure });
} }
// export function checkPerms(cookies: Cookies, perms: number): void { export function verifyJWT(cookies: Cookies): UserPayload {
// const JWT = cookies.get('jwt'); const JWT = cookies.get('jwt');
// if (!JWT) throw error(403, 'Unauthorized'); if (!JWT) throw error(403, 'Unauthorized');
// if (process.env.JWT_SECRET === undefined) { if (process.env.JWT_SECRET === undefined) {
// throw new Error('JWT_SECRET not defined'); throw new Error('JWT_SECRET not defined');
// } }
// const user = jwt.verify(JWT, process.env.JWT_SECRET) as User; return jwt.verify(JWT, process.env.JWT_SECRET) as UserPayload;
// if ((user.perms & perms) !== perms) { }
// throw error(403, 'Unauthorized');
// }
// }
// //
// export function hasPerms(cookies: Cookies, perms: number): boolean { // export function hasPerms(cookies: Cookies, perms: number): boolean {
// const JWT = cookies.get('jwt'); // const JWT = cookies.get('jwt');
@ -48,14 +45,13 @@ export function setJWT(cookies: Cookies, user: User) {
// } // }
export async function login(email: string, password: string): Promise<User> { export async function login(email: string, password: string): Promise<User> {
try {
const [user]: User[] = await sql` const [user]: User[] = await sql`
SELECT * FROM users SELECT * FROM users
WHERE email = ${email}; WHERE email = ${email};
`; `;
if (user) {
if (await bcrypt.compare(password, user.password_hash!)) { if (await bcrypt.compare(password, user.passwordHash!)) {
delete user.password_hash; delete user.passwordHash;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
sql` sql`
@ -66,15 +62,13 @@ export async function login(email: string, password: string): Promise<User> {
return user; return user;
} }
} catch {
throw Error('Error signing in ');
} }
throw Error('Invalid email or password'); throw Error('Invalid email or password');
} }
// await createUser(<User>{ // await createUser(<User>{
// name: 'Drake',
// username: 'drake',
// email: 'drake@marinodev.com', // email: 'drake@marinodev.com',
// password: 'password', // password: 'password'
// perms: 255,
// name: 'Drake'
// }); // });

View File

@ -3,6 +3,13 @@
import type { Item } from '$lib/types/item'; import type { Item } from '$lib/types/item';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import LocationIcon from '@lucide/svelte/icons/map-pinned'; import LocationIcon from '@lucide/svelte/icons/map-pinned';
import CheckIcon from '@lucide/svelte/icons/check';
import XIcon from '@lucide/svelte/icons/x';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { dateFormatOptions } from '$lib/shared';
import { approveDenyItem } from '$lib/db/items.remote';
import { invalidateAll } from '$app/navigation';
export let item: Item = <Item>{ export let item: Item = <Item>{
id: 2, id: 2,
@ -14,32 +21,71 @@
foundLocation: 'By the tennis courts.' foundLocation: 'By the tennis courts.'
}; };
export let admin = false; export let admin = false;
let timeSincePosted: number | string = (new Date().getTime() - item.foundDate.getTime()) / 1000 / 60 / 60 / 24; // days
if (timeSincePosted < 1) {
timeSincePosted = '<1';
} else {
timeSincePosted = Math.round(timeSincePosted);
}
</script> </script>
<a href="items/{item.id}" <div
class="bg-card text-card-foreground flex flex-col gap-2 rounded-xl border shadow-sm max-w-sm overflow-hidden min-2-3xs"> class="h-full bg-card text-card-foreground flex flex-col gap-2 rounded-xl border shadow-sm max-w-sm overflow-hidden min-2-3xs">
<img src="https://fbla26.marinodev.com/uploads/{item.id}.jpg" alt="" class="object-cover max-h-48"> <img src="https://fbla26.marinodev.com/uploads/{item.id}.jpg" alt="" class="object-cover max-h-48">
<div class="px-2 pb-2"> <div class="flex-col flex h-full px-2 pb-2">
<div>
<!-- <div class="font-bold inline-block">{item.title}</div>--> <!-- <div class="font-bold inline-block">{item.title}</div>-->
<!-- <div class="inline-block">--> <!-- <div class="inline-block">-->
<div>
{#if item.transferred} {#if item.transferred}
<Badge variant="secondary" class="float-right">In Lost & Found</Badge> <Badge variant="secondary" class="inline-block">In Lost & Found</Badge>
{:else} {:else}
<Badge variant="outline" class="float-right">With Finder</Badge> <Badge variant="secondary" class="inline-block">With Finder</Badge>
{/if} {/if}
<div>{item.description}</div> <Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger
>
<Badge variant="outline" class="inline-block">{timeSincePosted}
day{(timeSincePosted === 1 || timeSincePosted === '<1') ? '' : 's'} ago
</Badge>
</Tooltip.Trigger
>
<Tooltip.Content>
<p>{item.foundDate.toLocaleDateString('en-US', dateFormatOptions)}</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<div class="flex-1">{item.description}</div>
{#if item.foundLocation} {#if item.foundLocation}
<div class="pt-2"> <div class="mt-2">
<LocationIcon class="float-left mr-1" size={24} /> <LocationIcon class="float-left mr-1" size={24} />
<div>{item.foundLocation}</div> <div>{item.foundLocation}</div>
</div> </div>
{/if} {/if}
{#if admin && item.approvedDate === null}
<div class="mt-2 justify-between flex">
<Button variant="ghost" class="text-positive"
onclick={async () => {await approveDenyItem({id: item.id, approved: true});
invalidateAll()}}>
<CheckIcon />
Approve
</Button>
<Button variant="ghost" class="text-destructive"
onclick={async () => {await approveDenyItem({id: item.id, approved: false});
invalidateAll()}}>
<XIcon />
Deny
</Button>
</div>
{/if}
</div> </div>
</div> </div>
</a>

View File

@ -0,0 +1,19 @@
import Root from "./tooltip.svelte";
import Trigger from "./tooltip-trigger.svelte";
import Content from "./tooltip-content.svelte";
import Provider from "./tooltip-provider.svelte";
import Portal from "./tooltip-portal.svelte";
export {
Root,
Trigger,
Content,
Provider,
Portal,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
Provider as TooltipProvider,
Portal as TooltipPortal,
};

View File

@ -0,0 +1,52 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import TooltipPortal from "./tooltip-portal.svelte";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
side = "top",
children,
arrowClasses,
portalProps,
...restProps
}: TooltipPrimitive.ContentProps & {
arrowClasses?: string;
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof TooltipPortal>>;
} = $props();
</script>
<TooltipPortal {...portalProps}>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
{side}
class={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--bits-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...restProps}
>
{@render children?.()}
<TooltipPrimitive.Arrow>
{#snippet child({ props })}
<div
class={cn(
"bg-foreground z-50 size-2.5 rotate-45 rounded-[2px]",
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]",
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]",
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2",
"data-[side=left]:-translate-y-[calc(50%_-_3px)]",
arrowClasses
)}
{...props}
></div>
{/snippet}
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Content>
</TooltipPortal>

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ...restProps }: TooltipPrimitive.PortalProps = $props();
</script>
<TooltipPrimitive.Portal {...restProps} />

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ...restProps }: TooltipPrimitive.ProviderProps = $props();
</script>
<TooltipPrimitive.Provider {...restProps} />

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
</script>
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: TooltipPrimitive.RootProps = $props();
</script>
<TooltipPrimitive.Root bind:open {...restProps} />

View File

@ -0,0 +1,45 @@
import { getRequestEvent, query } from '$app/server';
import * as v from 'valibot';
import sql from '$lib/db/db.server';
import { verifyJWT } from '$lib/auth/index.server';
export const genDescription = query(async () => {
await new Promise((f) => setTimeout(f, 1000));
return 'A matte black water bottle with a black lid and a "BKLYN BENTO" logo on the side, resting on a tree trunk in a forest.';
});
export const approveDenyItem = query(
v.object({ id: v.number(), approved: v.boolean() }),
async ({ id, approved }) => {
console.log('called');
const { cookies } = getRequestEvent();
const userPayload = verifyJWT(cookies);
console.log('1');
if (approved) {
const reponse = await sql`
UPDATE items
SET
approved_date = NOW(),
emails = (
SELECT array_agg(DISTINCT email)
FROM (
SELECT ${userPayload.email} AS email
UNION ALL
SELECT u.email
FROM users u
WHERE (u.settings->>'notifyAllApprovedInquiries')::boolean = true
) t
)
WHERE id = ${id};
`;
console.log(reponse);
} else {
await sql`
DELETE FROM items WHERE id = ${id};
`;
}
}
);

View File

View File

@ -0,0 +1,15 @@
import { DefaultUserSettings, type User } from '$lib/types/user';
import sql from '$lib/db/db.server';
import bcrypt from 'bcrypt';
export async function createUser(user: User): Promise<User> {
const password_hash: string = await bcrypt.hash(user.password!, 12);
const response = await sql`
INSERT INTO users (username, email, name, password_hash, settings, created_at, last_sign_in)
VALUES (${user.username}, ${user.email}, ${user.name}, ${password_hash}, ${sql.json(DefaultUserSettings)}, NOW(), NOW()) RETURNING id;
`;
user.id = response[0].id;
return user;
}

View File

@ -1,5 +1,4 @@
import type { User } from '$lib/types/user'; import type { User } from '$lib/types/user';
import { fail } from '@sveltejs/kit';
export const getCookieValue = (name: string): string => export const getCookieValue = (name: string): string =>
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || ''; document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
@ -15,30 +14,40 @@ export const getUserFromJWT = (jwt: string): User => JSON.parse(atob(jwt.split('
// return userData; // return userData;
// }; // };
export function getFormString(data: FormData, key: string): string | undefined { // export function getFormString(data: FormData, key: string): string | undefined {
const value = data.get(key); // const value = data.get(key);
if (value === null) { // if (value === null) {
return undefined; // return undefined;
} // }
//
// if (typeof value !== 'string') {
// throw fail(400, {
// error: `Incorrect input in field ${key}.`,
// success: false
// });
// }
// return value.trim();
// }
if (typeof value !== 'string') { export function getFormString(data: FormData, key: string) {
throw fail(400, { const value = data.get(key);
error: `Incorrect input in field ${key}.`,
success: false if (!value) return null;
}); if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed !== '') return trimmed;
} }
return value.trim(); throw Error(`Invalid field ${key}.`);
} }
export function getRequiredFormString(data: FormData, key: string) { export function getRequiredFormString(data: FormData, key: string) {
const value = data.get(key); const value = data.get(key);
if (typeof value !== 'string' || value === '') {
throw fail(400, { if (typeof value === 'string') {
error: `Missing required field ${key}.`, const trimmed = value.trim();
success: false if (trimmed !== '') return trimmed;
});
} }
return value.trim(); throw Error(`Missing/invalid required field ${key}.`);
} }
export const dateFormatOptions: Intl.DateTimeFormatOptions = { export const dateFormatOptions: Intl.DateTimeFormatOptions = {

View File

@ -4,7 +4,7 @@ export interface User {
email: string; email: string;
name: string; name: string;
password?: string; password?: string;
password_hash?: string; passwordHash?: string;
settings?: UserSettings; settings?: UserSettings;
createdAt: Date; createdAt: Date;
lastSignIn: Date; lastSignIn: Date;
@ -17,11 +17,11 @@ export interface UserPayload {
name: string; name: string;
} }
export interface UserSettings { export type UserSettings = {
staleItemDays: number; staleItemDays: number;
notifyAllApprovedInquiries?: boolean; notifyAllApprovedInquiries?: boolean;
notifyAllTurnedInInquiries?: boolean; notifyAllTurnedInInquiries?: boolean;
} };
export const DefaultUserSettings: UserSettings = { export const DefaultUserSettings: UserSettings = {
staleItemDays: 30, staleItemDays: 30,

View File

@ -1,89 +0,0 @@
import type { Actions, PageServerLoad } from './$types';
import { getFormString, getRequiredFormString } from '$lib/shared';
import { fail } from '@sveltejs/kit';
import path from 'path';
import sql from '$lib/db/db.server';
import sharp from 'sharp';
import { writeFileSync } from 'node:fs';
import type { Item } from '$lib/types/item';
export const load: PageServerLoad = async () => {
const items: Item[] = await sql`SELECT * FROM items;`;
return {
items
};
};
export const actions = {
create: async ({ request }) => {
const data = await request.formData();
const description = getRequiredFormString(data, 'description');
const foundLocation = getFormString(data, 'foundLocation') || null;
const email = getFormString(data, 'email') || null;
const location = getFormString(data, 'location') || null;
const file = data.get('image')!;
if (!email && location !== 'turnedIn') {
fail(400, {
error: "Email is required if it is still in finder's possession",
success: false
});
}
if (!(file instanceof File)) {
return fail(400, { error: 'No file uploaded or file is invalid', success: false });
}
// Convert File → Buffer
const inputBuffer = Buffer.from(await file.arrayBuffer());
// Detect format (Sharp does this internally)
const image = sharp(inputBuffer);
const metadata = await image.metadata();
let outputBuffer: Buffer;
if (metadata.format === 'jpeg') {
// Already JPG → keep as-is
outputBuffer = inputBuffer;
} else {
// Convert to JPG
outputBuffer = await image
.jpeg({ quality: 90 }) // adjust if needed
.toBuffer();
}
let emailList = null;
if (email) {
emailList = [email];
}
const response = await sql`
INSERT INTO items (
email,
description,
transferred,
found_location
) VALUES (
${emailList},
${description},
${location === 'turnedIn'},
${foundLocation}
)
RETURNING id;
`;
try {
// It's a good idea to validate the filename to prevent path traversal attacks
const savePath = path.join('uploads', `${response[0]['id']}.jpg`);
writeFileSync(savePath, outputBuffer);
} catch (err) {
console.error('File upload failed:', err);
return fail(500, { error: 'Internal server error during file upload', success: false });
}
return { success: true };
}
} satisfies Actions;

View File

@ -1,111 +0,0 @@
<script lang="ts">
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte';
import type { PageProps } from './$types';
import { EMAIL_REGEX_STRING } from '$lib/consts';
import { genDescription } from './gen-desc.remote';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Label } from '$lib/components/ui/label';
import ItemListing from '$lib/components/custom/item-listing.svelte';
let itemLocation: string | undefined = $state('');
let foundLocation: string | undefined = $state();
let description: string | undefined = $state();
let isGenerating = $state(false);
async function onSelect() {
isGenerating = true;
description = await genDescription();
isGenerating = false;
}
let { data }: PageProps = $props();
</script>
<div class="max-w-7xl mx-auto px-4">
<div class="justify-between flex">
<h1 class="font-semibold text-4xl mb-4 mt-2">Found Items</h1>
<div class="inline-block">
<Dialog.Root>
<Dialog.Trigger class={buttonVariants({ variant: 'default' })} type="button"
>Post an item
</Dialog.Trigger
>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Submit Found Item</Dialog.Title>
<Dialog.Description>
Your item will need to be approved before becoming public.
</Dialog.Description>
</Dialog.Header>
<form method="post" action="?/create" enctype="multipart/form-data">
<Field.Group>
<ImageUpload onSelect={onSelect} required={true} />
<Field.Field>
<Field.Label for="description">
Description<span class="text-error">*</span>
</Field.Label>
<Input id="description" name="description" bind:value={description}
placeholder="A red leather book bag..."
required />
</Field.Field>
<Field.Field>
<Field.Label for="foundLocation">
Where did you find it?
</Field.Label>
<Input id="foundLocation" name="foundLocation" bind:value={foundLocation}
placeholder="By the tennis courts." required />
</Field.Field>
<RadioGroup.Root name="location" bind:value={itemLocation}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="finderPossession" id="finderPossession" />
<Label for="finderPossession">I still have the item.</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="turnedIn" id="turnedIn" />
<Label for="turnedIn">I turned the item in to the school lost and found.</Label>
</div>
</RadioGroup.Root>
<Field.Field class={itemLocation !== 'finderPossession' ? 'disabled hidden' : ''}>
<Field.Label for="email">
Your Email
</Field.Label>
<Input id="email" name="email" placeholder="name@domain.com"
class={itemLocation !== 'finderPossession' ? 'disabled' : ''} pattern={EMAIL_REGEX_STRING}
required={itemLocation === 'finderPossesion'} />
<!-- <Field.Error>Enter a valid email address.</Field.Error>-->
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Dialog.Close class={buttonVariants({ variant: "outline" })} type="button"
>Cancel
</Dialog.Close
>
<Button type="submit">Submit</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
</div>
</div>
<div class="justify-start grid gap-4 grid-cols-[repeat(auto-fill,minmax(16rem,max-content))]">
{#each data.items as item (item.id)}
<ItemListing item={item} />
{/each}
</div>
</div>
{#if isGenerating}
<div class="fixed inset-0 bg-black/75 z-999999 w-screen h-screen justify-center items-center flex">
<p class="text-6xl text-primary">Loading...</p>
</div>
{/if}

View File

@ -1,7 +0,0 @@
import { query } from '$app/server';
export const genDescription = query(async () => {
await new Promise((f) => setTimeout(f, 1000));
return 'A matte black water bottle with a black lid and a "BKLYN BENTO" logo on the side, resting on a tree trunk in a forest.';
});

View File

@ -0,0 +1,115 @@
import type { Actions, PageServerLoad } from '$types';
import { fail } from '@sveltejs/kit';
import path from 'path';
import sql from '$lib/db/db.server';
import sharp from 'sharp';
import { writeFileSync } from 'node:fs';
import type { Item } from '$lib/types/item';
import { getFormString, getRequiredFormString } from '$lib/shared';
export const load: PageServerLoad = async () => {
const items: Item[] = await sql`SELECT * FROM items;`;
return {
items
};
};
export const actions: Actions = {
create: async ({ request }) => {
const data = await request.formData();
let description: string;
let foundLocation: string | null;
let email: string | null;
let location: string | null;
try {
description = getRequiredFormString(data, 'description');
foundLocation = getFormString(data, 'foundLocation');
email = getFormString(data, 'email');
location = getFormString(data, 'location');
const file = data.get('image');
if (file === null)
return fail(400, {
message: `Missing required field image.`,
success: false
});
if (!email && location !== 'turnedIn') {
fail(400, {
message: "Email is required if it is still in finder's possession",
success: false
});
}
if (!(file instanceof File)) {
return fail(400, { message: 'No file uploaded or file is invalid', success: false });
}
// Convert File → Buffer
const inputBuffer = Buffer.from(await file.arrayBuffer());
// Detect format (Sharp does this internally)
const image = sharp(inputBuffer);
const metadata = await image.metadata();
let outputBuffer: Buffer;
if (metadata.format === 'jpeg') {
// Already JPG → keep as-is
outputBuffer = inputBuffer;
} else {
// Convert to JPG
outputBuffer = await image
.jpeg({ quality: 90 }) // adjust if needed
.toBuffer();
}
const response = await sql`
INSERT INTO items (
emails,
description,
transferred,
found_location
) VALUES (
${
location === 'turnedIn'
? sql`(
SELECT array_agg(DISTINCT email)
FROM (
SELECT ${email} AS email
UNION ALL
SELECT u.email
FROM users u
WHERE (u.settings->>'notifyAllTurnedInInquiries')::boolean = true
) t
)`
: [email]
},
${description},
${location === 'turnedIn'},
${foundLocation}
)
RETURNING id;
`;
try {
// It's a good idea to validate the filename to prevent path traversal attacks
const savePath = path.join('uploads', `${response[0]['id']}.jpg`);
writeFileSync(savePath, outputBuffer);
} catch (err) {
console.error('File upload failed:', err);
return fail(500, { message: 'Internal server error during file upload', success: false });
}
} catch (e) {
return fail(400, {
message: e instanceof Error ? e.message : 'Unknown error occurred',
success: false
});
}
return { success: true };
}
} satisfies Actions;

View File

@ -0,0 +1,139 @@
<script lang="ts">
import { Button, buttonVariants } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte';
import type { PageProps } from '$types';
import { EMAIL_REGEX_STRING } from '$lib/consts';
import { genDescription } from '$lib/db/items.remote';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Label } from '$lib/components/ui/label';
import ItemListing from '$lib/components/custom/item-listing.svelte';
import { FieldSeparator } from '$lib/components/ui/field';
// import { type Item } from '$lib/types/item';
let itemLocation: string | undefined = $state('');
let foundLocation: string | undefined = $state();
let description: string | undefined = $state();
let isGenerating = $state(false);
async function onSelect() {
isGenerating = true;
description = await genDescription();
isGenerating = false;
}
let { data }: PageProps = $props();
</script>
<div class="max-w-7xl mx-auto px-4">
<div class="justify-between flex">
<h1 class="font-semibold text-4xl mb-4 mt-2">Found Items</h1>
<div class="inline-block">
<Dialog.Root>
<Dialog.Trigger class={buttonVariants({ variant: 'default' })} type="button"
>Post an item
</Dialog.Trigger
>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Submit Found Item</Dialog.Title>
<Dialog.Description>
Your item will need to be approved before becoming public.
</Dialog.Description>
</Dialog.Header>
<form method="post" action="?/create" enctype="multipart/form-data">
<Field.Group>
<ImageUpload onSelect={onSelect} required={true} />
<Field.Field>
<Field.Label for="description">
Description<span class="text-error">*</span>
</Field.Label>
<Input id="description" name="description" bind:value={description}
placeholder="A red leather book bag..." maxlength={200}
required />
</Field.Field>
<Field.Field>
<Field.Label for="foundLocation">
Where did you find it?
</Field.Label>
<Input id="foundLocation" name="foundLocation" bind:value={foundLocation}
placeholder="By the tennis courts." required />
</Field.Field>
<RadioGroup.Root name="location" bind:value={itemLocation}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="finderPossession" id="finderPossession" />
<Label for="finderPossession">I still have the item.</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="turnedIn" id="turnedIn" />
<Label for="turnedIn">I turned the item in to the school lost and found.</Label>
</div>
</RadioGroup.Root>
<Field.Field class={itemLocation !== 'finderPossession' ? 'disabled hidden' : ''}>
<Field.Label for="email">
Your Email
</Field.Label>
<Input id="email" name="email" placeholder="name@domain.com"
class={itemLocation !== 'finderPossession' ? 'disabled' : ''} pattern={EMAIL_REGEX_STRING}
required={itemLocation === 'finderPossesion'} />
<!-- <Field.Error>Enter a valid email address.</Field.Error>-->
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Dialog.Close class={buttonVariants({ variant: "outline" })} type="button"
>Cancel
</Dialog.Close
>
<Button type="submit">Submit</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
</div>
</div>
<div class="grid gap-4 grid-cols-[repeat(auto-fill,minmax(16rem,max-content))]">
{#if data.user && data.items.some(
(item) => item.approvedDate === null
)}
<FieldSeparator class="col-span-full text-lg mb-2 h-8">
Pending items
</FieldSeparator>
{/if}
{#each data.items as item (item.id)}
{#if item.approvedDate === null}
<ItemListing item={item} admin={data.user !== null} />
{/if}
{/each}
{#if data.user && data.items.some(
(item) => item.approvedDate !== null
)}
<FieldSeparator class="col-span-full text-lg my-2 h-8">
Public items
</FieldSeparator>
{/if}
{#each data.items as item (item.id)}
{#if item.approvedDate !== null}
<ItemListing item={item} admin={data.user !== null} />
{/if}
{/each}
</div>
</div>
{#if isGenerating}
<div class="fixed inset-0 bg-black/75 z-999999 w-screen h-screen justify-center items-center flex">
<p class="text-6xl text-primary">Loading...</p>
</div>
{/if}

View File

@ -0,0 +1,20 @@
import { type Actions, fail, redirect } from '@sveltejs/kit';
import { getRequiredFormString } from '$lib/shared';
import { login, setJWTCookie } from '$lib/auth/index.server';
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
try {
const email = getRequiredFormString(data, 'email');
const password = getRequiredFormString(data, 'password');
const user = await login(email, password);
setJWTCookie(cookies, user);
} catch (e) {
return fail(400, { message: e instanceof Error ? e.message : 'Unknown error occurred' });
}
throw redirect(303, '/');
}
} satisfies Actions;

View File

@ -1,5 +1,14 @@
<script lang="ts"> <script lang="ts">
import LoginForm from './login-form.svelte'; import type { PageProps } from '$types';
import {
FieldGroup,
Field,
FieldLabel
} from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
let { form }: PageProps = $props();
</script> </script>
<div class="grid min-h-svh lg:grid-cols-2"> <div class="grid min-h-svh lg:grid-cols-2">
@ -7,7 +16,41 @@
<div class="flex-1"></div> <div class="flex-1"></div>
<div class="justify-center flex"> <div class="justify-center flex">
<div class="w-full max-w-xs"> <div class="w-full max-w-xs">
<LoginForm /> <form class="flex flex-col gap-6" method="post">
<FieldGroup>
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-2xl font-bold">Login to your account</h1>
<p class="text-muted-foreground text-sm text-balance">
Only admins need to log in.<br>Lost something? Go <a href="/" class="underline text-primary">home</a>.
</p>
</div>
<Field>
<FieldLabel for="email">Email</FieldLabel>
<Input id="email" type="email" name="email" placeholder="m@example.com" required />
</Field>
<Field>
<div class="flex items-center">
<FieldLabel for="password">Password</FieldLabel>
<!-- <a href="##" class="ms-auto text-sm underline-offset-4 hover:underline">-->
<!-- Forgot your password?-->
<!-- </a>-->
</div>
<Input id="password" name="password" type="password" required />
{#if form?.message}
<p class="text-error">{form.message}</p>
{/if}
</Field>
<Field>
<Button type="submit">Login</Button>
</Field>
<!-- <Field>-->
<!-- <FieldDescription class="text-center">-->
<!-- Don't have an account?-->
<!-- <a href="/signup" class="underline underline-offset-4">Sign up</a>-->
<!-- </FieldDescription>-->
<!-- </Field>-->
</FieldGroup>
</form>
</div> </div>
</div> </div>
<div class="flex-3"></div> <div class="flex-3"></div>

View File

@ -8,14 +8,18 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { cn, type WithElementRef } from '$lib/utils.js'; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLFormAttributes } from 'svelte/elements'; import type { HTMLFormAttributes } from 'svelte/elements';
import type { PageProps } from '$types';
let { let {
class: className, class: className,
form,
...restProps ...restProps
}: WithElementRef<HTMLFormAttributes> = $props(); }: WithElementRef<HTMLFormAttributes> & { form?: PageProps } = $props();
</script> </script>
<form class={cn("flex flex-col gap-6", className)} {...restProps}> <form class={cn("flex flex-col gap-6", className)} {...restProps} method="post">
<FieldGroup> <FieldGroup>
<div class="flex flex-col items-center gap-1 text-center"> <div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-2xl font-bold">Login to your account</h1> <h1 class="text-2xl font-bold">Login to your account</h1>
@ -25,14 +29,14 @@
</div> </div>
<Field> <Field>
<FieldLabel for="email">Email</FieldLabel> <FieldLabel for="email">Email</FieldLabel>
<Input id="email" type="email" placeholder="m@example.com" required /> <Input id="email" name="email" type="email" placeholder="m@example.com" required />
</Field> </Field>
<Field> <Field>
<div class="flex items-center"> <div class="flex items-center">
<FieldLabel for="password">Password</FieldLabel> <FieldLabel for="password">Password</FieldLabel>
<a href="##" class="ms-auto text-sm underline-offset-4 hover:underline"> <!-- <a href="##" class="ms-auto text-sm underline-offset-4 hover:underline">-->
Forgot your password? <!-- Forgot your password?-->
</a> <!-- </a>-->
</div> </div>
<Input id="password" type="password" required /> <Input id="password" type="password" required />
</Field> </Field>

View File

@ -1,7 +1,7 @@
import { page } from '@vitest/browser/context'; import { page } from '@vitest/browser/context';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte'; import { render } from 'vitest-browser-svelte';
import Page from './+page.svelte'; import Page from './items/+page.svelte';
describe('/+page.svelte', () => { describe('/+page.svelte', () => {
it('should render h1', async () => { it('should render h1', async () => {