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",
|
"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",
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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'
|
|
||||||
// });
|
// });
|
||||||
|
|||||||
@ -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>
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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 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 = {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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">
|
<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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user