items revisions
This commit is contained in:
parent
4ea6549ac7
commit
8ea632a14f
17
package-lock.json
generated
17
package-lock.json
generated
@ -17,7 +17,8 @@
|
||||
"nodemailer": "^7.0.13",
|
||||
"postgres": "^3.4.7",
|
||||
"sharp": "^0.34.5",
|
||||
"svelte-preprocess": "^6.0.3"
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"valibot": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
@ -6646,6 +6647,20 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
||||
@ -62,6 +62,7 @@
|
||||
"nodemailer": "^7.0.13",
|
||||
"postgres": "^3.4.7",
|
||||
"sharp": "^0.34.5",
|
||||
"svelte-preprocess": "^6.0.3"
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"valibot": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@
|
||||
--sidebar-ring: oklch(0.606 0.25 292.717);
|
||||
--warning: oklch(0.84 0.16 84);
|
||||
--error: oklch(0.577 0.245 27.325);
|
||||
--positive: oklch(0.5 0.2067 147.18);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@ -75,6 +76,7 @@
|
||||
--sidebar-ring: oklch(0.541 0.281 293.009);
|
||||
--warning: oklch(0.84 0.16 84);
|
||||
--error: oklch(0.704 0.191 22.216);
|
||||
--positive: oklch(0.7522 0.2067 147.18);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@ -115,6 +117,7 @@
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-warning: var(--warning);
|
||||
--color-error: var(--error);
|
||||
--color-positive: var(--positive);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { User } from '$lib/types/user';
|
||||
import type { User, UserPayload } from '$lib/types/user';
|
||||
import bcrypt from 'bcrypt';
|
||||
import sql from '$lib/db/db.server';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { type Cookies, error } from '@sveltejs/kit';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export function setJWT(cookies: Cookies, user: User) {
|
||||
export function setJWTCookie(cookies: Cookies, user: User) {
|
||||
const payload = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@ -12,10 +12,10 @@ export function setJWT(cookies: Cookies, user: User) {
|
||||
};
|
||||
|
||||
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) {
|
||||
throw new Error('BASE_URL not defined');
|
||||
throw Error('BASE_URL not defined');
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// export function checkPerms(cookies: Cookies, perms: number): void {
|
||||
// const JWT = cookies.get('jwt');
|
||||
// if (!JWT) throw error(403, 'Unauthorized');
|
||||
// if (process.env.JWT_SECRET === undefined) {
|
||||
// throw new Error('JWT_SECRET not defined');
|
||||
// }
|
||||
// const user = jwt.verify(JWT, process.env.JWT_SECRET) as User;
|
||||
// if ((user.perms & perms) !== perms) {
|
||||
// throw error(403, 'Unauthorized');
|
||||
// }
|
||||
// }
|
||||
export function verifyJWT(cookies: Cookies): UserPayload {
|
||||
const JWT = cookies.get('jwt');
|
||||
if (!JWT) throw error(403, 'Unauthorized');
|
||||
if (process.env.JWT_SECRET === undefined) {
|
||||
throw new Error('JWT_SECRET not defined');
|
||||
}
|
||||
return jwt.verify(JWT, process.env.JWT_SECRET) as UserPayload;
|
||||
}
|
||||
//
|
||||
// export function hasPerms(cookies: Cookies, perms: number): boolean {
|
||||
// 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> {
|
||||
try {
|
||||
const [user]: User[] = await sql`
|
||||
SELECT * FROM users
|
||||
WHERE email = ${email};
|
||||
`;
|
||||
|
||||
if (await bcrypt.compare(password, user.password_hash!)) {
|
||||
delete user.password_hash;
|
||||
if (user) {
|
||||
if (await bcrypt.compare(password, user.passwordHash!)) {
|
||||
delete user.passwordHash;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
sql`
|
||||
@ -66,15 +62,13 @@ export async function login(email: string, password: string): Promise<User> {
|
||||
|
||||
return user;
|
||||
}
|
||||
} catch {
|
||||
throw Error('Error signing in ');
|
||||
}
|
||||
throw Error('Invalid email or password');
|
||||
}
|
||||
|
||||
// await createUser(<User>{
|
||||
// name: 'Drake',
|
||||
// username: 'drake',
|
||||
// email: 'drake@marinodev.com',
|
||||
// password: 'password',
|
||||
// perms: 255,
|
||||
// name: 'Drake'
|
||||
// password: 'password'
|
||||
// });
|
||||
|
||||
@ -3,6 +3,13 @@
|
||||
import type { Item } from '$lib/types/item';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
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>{
|
||||
id: 2,
|
||||
@ -14,32 +21,71 @@
|
||||
foundLocation: 'By the tennis courts.'
|
||||
};
|
||||
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>
|
||||
|
||||
<a href="items/{item.id}"
|
||||
class="bg-card text-card-foreground flex flex-col gap-2 rounded-xl border shadow-sm max-w-sm overflow-hidden min-2-3xs">
|
||||
<div
|
||||
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">
|
||||
<div class="px-2 pb-2">
|
||||
<div>
|
||||
<div class="flex-col flex h-full px-2 pb-2">
|
||||
|
||||
<!-- <div class="font-bold inline-block">{item.title}</div>-->
|
||||
<!-- <div class="inline-block">-->
|
||||
<div>
|
||||
|
||||
{#if item.transferred}
|
||||
<Badge variant="secondary" class="float-right">In Lost & Found</Badge>
|
||||
<Badge variant="secondary" class="inline-block">In Lost & Found</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline" class="float-right">With Finder</Badge>
|
||||
<Badge variant="secondary" class="inline-block">With Finder</Badge>
|
||||
{/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}
|
||||
<div class="pt-2">
|
||||
<div class="mt-2">
|
||||
<LocationIcon class="float-left mr-1" size={24} />
|
||||
<div>{item.foundLocation}</div>
|
||||
</div>
|
||||
{/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>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
19
src/lib/components/ui/tooltip/index.ts
Normal file
19
src/lib/components/ui/tooltip/index.ts
Normal 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,
|
||||
};
|
||||
52
src/lib/components/ui/tooltip/tooltip-content.svelte
Normal file
52
src/lib/components/ui/tooltip/tooltip-content.svelte
Normal 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>
|
||||
7
src/lib/components/ui/tooltip/tooltip-portal.svelte
Normal file
7
src/lib/components/ui/tooltip/tooltip-portal.svelte
Normal file
@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: TooltipPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<TooltipPrimitive.Portal {...restProps} />
|
||||
7
src/lib/components/ui/tooltip/tooltip-provider.svelte
Normal file
7
src/lib/components/ui/tooltip/tooltip-provider.svelte
Normal file
@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: TooltipPrimitive.ProviderProps = $props();
|
||||
</script>
|
||||
|
||||
<TooltipPrimitive.Provider {...restProps} />
|
||||
7
src/lib/components/ui/tooltip/tooltip-trigger.svelte
Normal file
7
src/lib/components/ui/tooltip/tooltip-trigger.svelte
Normal 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} />
|
||||
7
src/lib/components/ui/tooltip/tooltip.svelte
Normal file
7
src/lib/components/ui/tooltip/tooltip.svelte
Normal 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} />
|
||||
45
src/lib/db/items.remote.ts
Normal file
45
src/lib/db/items.remote.ts
Normal 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};
|
||||
`;
|
||||
}
|
||||
}
|
||||
);
|
||||
0
src/lib/db/items.server.ts
Normal file
0
src/lib/db/items.server.ts
Normal 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;
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import type { User } from '$lib/types/user';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
|
||||
export const getCookieValue = (name: string): string =>
|
||||
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;
|
||||
// };
|
||||
|
||||
export function getFormString(data: FormData, key: string): string | undefined {
|
||||
const value = data.get(key);
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
// export function getFormString(data: FormData, key: string): string | undefined {
|
||||
// const value = data.get(key);
|
||||
// if (value === null) {
|
||||
// return undefined;
|
||||
// }
|
||||
//
|
||||
// if (typeof value !== 'string') {
|
||||
// throw fail(400, {
|
||||
// error: `Incorrect input in field ${key}.`,
|
||||
// success: false
|
||||
// });
|
||||
// }
|
||||
// return value.trim();
|
||||
// }
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
throw fail(400, {
|
||||
error: `Incorrect input in field ${key}.`,
|
||||
success: false
|
||||
});
|
||||
export function getFormString(data: FormData, key: string) {
|
||||
const value = data.get(key);
|
||||
|
||||
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) {
|
||||
const value = data.get(key);
|
||||
if (typeof value !== 'string' || value === '') {
|
||||
throw fail(400, {
|
||||
error: `Missing required field ${key}.`,
|
||||
success: false
|
||||
});
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed !== '') return trimmed;
|
||||
}
|
||||
return value.trim();
|
||||
throw Error(`Missing/invalid required field ${key}.`);
|
||||
}
|
||||
|
||||
export const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
|
||||
@ -4,7 +4,7 @@ export interface User {
|
||||
email: string;
|
||||
name: string;
|
||||
password?: string;
|
||||
password_hash?: string;
|
||||
passwordHash?: string;
|
||||
settings?: UserSettings;
|
||||
createdAt: Date;
|
||||
lastSignIn: Date;
|
||||
@ -17,11 +17,11 @@ export interface UserPayload {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
export type UserSettings = {
|
||||
staleItemDays: number;
|
||||
notifyAllApprovedInquiries?: boolean;
|
||||
notifyAllTurnedInInquiries?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export const DefaultUserSettings: UserSettings = {
|
||||
staleItemDays: 30,
|
||||
|
||||
@ -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;
|
||||
@ -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}
|
||||
|
||||
@ -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.';
|
||||
});
|
||||
115
src/routes/items/+page.server.ts
Normal file
115
src/routes/items/+page.server.ts
Normal 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;
|
||||
139
src/routes/items/+page.svelte
Normal file
139
src/routes/items/+page.svelte
Normal 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}
|
||||
|
||||
@ -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;
|
||||
@ -1,5 +1,14 @@
|
||||
<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>
|
||||
|
||||
<div class="grid min-h-svh lg:grid-cols-2">
|
||||
@ -7,7 +16,41 @@
|
||||
<div class="flex-1"></div>
|
||||
<div class="justify-center flex">
|
||||
<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 class="flex-3"></div>
|
||||
|
||||
@ -8,14 +8,18 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLFormAttributes } from 'svelte/elements';
|
||||
import type { PageProps } from '$types';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
form,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLFormAttributes> = $props();
|
||||
}: WithElementRef<HTMLFormAttributes> & { form?: PageProps } = $props();
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<form class={cn("flex flex-col gap-6", className)} {...restProps}>
|
||||
<form class={cn("flex flex-col gap-6", className)} {...restProps} 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>
|
||||
@ -25,14 +29,14 @@
|
||||
</div>
|
||||
<Field>
|
||||
<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>
|
||||
<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>
|
||||
<!-- <a href="##" class="ms-auto text-sm underline-offset-4 hover:underline">-->
|
||||
<!-- Forgot your password?-->
|
||||
<!-- </a>-->
|
||||
</div>
|
||||
<Input id="password" type="password" required />
|
||||
</Field>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { page } from '@vitest/browser/context';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import Page from './+page.svelte';
|
||||
import Page from './items/+page.svelte';
|
||||
|
||||
describe('/+page.svelte', () => {
|
||||
it('should render h1', async () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user