Compare commits

...

3 Commits

Author SHA1 Message Date
3955f95830 cleanup
Some checks failed
ci / deploy (push) Blocked by required conditions
ci / docker_image (push) Has been cancelled
2026-02-07 01:03:12 -06:00
60b5705107 readme 2026-02-07 01:01:37 -06:00
cd128d7914 account page 2026-02-07 00:45:35 -06:00
18 changed files with 547 additions and 147 deletions

17
.env.example Normal file
View File

@ -0,0 +1,17 @@
POSTGRES_HOST=192.168.0.200
POSTGRES_PORT=5432
POSTGRES_DB=fbla
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgrespw
JWT_SECRET=CHANGE_ME
BASE_URL=https://fbla26.marinodev.com
EMAIL_HOST=marinodev.com
EMAIL_PORT=465
EMAIL_USER=westuffind@marinodev.com
EMAIL_PASS=CHANGE_ME
FBLA26_PORT=8000
BODY_SIZE_LIMIT=10MB
LLAMA_PORT=8001
LLAMA_HOST=192.168.0.200
LLAMA_MODEL=Qwen3VL-2B-Instruct-Q4_K_M.gguf
LLAMA_MMPROJ=mmproj-Qwen3VL-2B-Instruct-Q8_0.gguf

View File

@ -1,38 +1,93 @@
# sv # CareerConnect - FBLA 2025
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). ## Overview
## Creating a project This is a lost and found application built using [SvelteKit](https://kit.svelte.dev/) for the 2026 FBLA Website Coding &
Development event. It allows users to browse items, post found items, and manage them. The
application is designed for fast performance and a seamless user experience.
If you're seeing this, you've probably already done this step. Congrats! ## Features
- User authentication (login/signup/logout)
- Email-only token-based methods for non-admins
- Browse/search items
- Post found items
- Inquire about items
- Claim items
- Email notifications
- Themes
## Installation
To set up the project locally, follow these steps:
### Prerequisites
- [Node.js](https://nodejs.org/) (LTS recommended)
- [npm](https://www.npmjs.com/) or [pnpm](https://pnpm.io/)
### Clone the repository
```sh ```sh
# create a new project in the current directory git clone https://git.marinodev.com/MarinoDev/FBLA25
npx sv create cd FBLA25
# create a new project in my-app
npx sv create my-app
``` ```
## Developing Create a `.env` file in the root directory and configure environment variables. `.env.example` is provided as a
template.
Download a LLaMA compatible LLM (and mmproj) to `llm-models`. I
recommend [Qwen3-VL-2B-Instruct](https://huggingface.co/Qwen/Qwen3-VL-2B-Instruct-GGUF).
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: ### Docker
A `Dockerfile` and `docker-compose.yml` file are provided for running the application in a Docker container.
### Manual
Using Docker is strongly recommended, as it bundles the database and the AI.
#### Install dependencies
```sh
npm install
```
#### Start the development server
```sh ```sh
npm run dev npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
``` ```
## Building Go to `http://localhost:5173/` (or the port shown in the terminal).
To create a production version of your app: ## Deployment
To deploy the application, build it using:
```sh ```sh
npm run build npm run build
node build
``` ```
You can preview the production build with `npm run preview`. ## Resources Used
### Technologies
- [SvelteKit](https://kit.svelte.dev/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Shadcn (Svelte version)](https://www.shadcn-svelte.com)
### Libraries
- [dotenv](https://www.npmjs.com/package/dotenv)
- [bcrypt](https://www.npmjs.com/package/bcrypt)
- [desm](https://www.npmjs.com/package/desm)
- [nodemailer](https://www.npmjs.com/package/nodemailer)
- [jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken)
- [postgres.js](https://www.npmjs.com/package/postgres)
- [lucide](https://www.npmjs.com/package/@lucide/svelte)
- [sharp](https://www.npmjs.com/package/sharp)
- [valibot](https://www.npmjs.com/package/valibot)
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View File

@ -34,9 +34,9 @@ services:
- ./llm-models:/models:ro - ./llm-models:/models:ro
command: command:
- -m - -m
- /models/Qwen3VL-2B-Instruct-Q4_K_M.gguf - /models/${LLAMA_MODEL}
- --mmproj - --mmproj
- /models/mmproj-Qwen3VL-2B-Instruct-Q8_0.gguf - /models/${LLAMA_MMPROJ}
- --host - --host
- 0.0.0.0 - 0.0.0.0
- --port - --port

View File

@ -23,7 +23,7 @@
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form method="post" action="?/claim"> <form method="post" action="?/claim&id={item?.id}">
<Field.Group> <Field.Group>
<div class="flex gap-4"> <div class="flex gap-4">

View File

@ -7,11 +7,13 @@
import XIcon from '@lucide/svelte/icons/x'; import XIcon from '@lucide/svelte/icons/x';
import PencilIcon from '@lucide/svelte/icons/pencil'; import PencilIcon from '@lucide/svelte/icons/pencil';
import NotebookPenIcon from '@lucide/svelte/icons/notebook-pen'; import NotebookPenIcon from '@lucide/svelte/icons/notebook-pen';
import TrashIcon from '@lucide/svelte/icons/trash';
import StarIcon from '@lucide/svelte/icons/star'; import StarIcon from '@lucide/svelte/icons/star';
import ArchiveRestoreIcon from '@lucide/svelte/icons/archive-restore';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { dateFormatOptions } from '$lib/shared'; import { dateFormatOptions } from '$lib/shared';
import { approveDenyItem } from '$lib/db/items.remote'; import { approveDenyItem, restoreClaimedItem } from '$lib/db/items.remote';
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import NoImagePlaceholder from './no-image-placeholder.svelte'; import NoImagePlaceholder from './no-image-placeholder.svelte';
@ -98,11 +100,26 @@
Deny Deny
</Button> </Button>
{/if} {/if}
{#if item.claimedDate === null}
<Button variant="ghost" class="text-action" <Button variant="ghost" class="text-action"
onclick={() => {editCallback(item)}}> onclick={() => {editCallback(item)}}>
<PencilIcon /> <PencilIcon />
Manage Manage
</Button> </Button>
{:else}
<Button variant="ghost" class="text-destructive"
onclick={async () => {await approveDenyItem({id: item.id, approved: false});
invalidateAll()}}>
<TrashIcon />
Delete
</Button>
<Button variant="ghost" class="text-action"
onclick={async () => {await restoreClaimedItem(item.id);
invalidateAll()}}>
<ArchiveRestoreIcon />
Restore
</Button>
{/if}
</div> </div>
{:else} {:else}

View File

@ -0,0 +1,7 @@
import Root from "./switch.svelte";
export {
Root,
//
Root as Switch,
};

View File

@ -0,0 +1,29 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
class={cn(
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>

View File

@ -25,4 +25,5 @@ const EMAIL_REGEX =
/^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_'+\-]@([a-z0-9][a-z0-9\-]*\.)+[a-z]{2,}$/i; /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_'+\-]@([a-z0-9][a-z0-9\-]*\.)+[a-z]{2,}$/i;
// Replace single quote with HTML entity or remove it from the character class // Replace single quote with HTML entity or remove it from the character class
export const EMAIL_REGEX_STRING = EMAIL_REGEX.source.replace(/'/g, '&#39;'); export const EMAIL_REGEX_STRING =
"^(?!\\.)(?!.*\\.\\.)([a-zA-Z0-9_'+\\-\\.]*)[a-zA-Z0-9_'+\\-]@([a-zA-Z0-9][a-zA-Z0-9\\-]*\\.)+[a-zA-Z]{2,}$";

View File

@ -58,3 +58,14 @@ export const approveDenyItem = command(
} }
} }
); );
export const restoreClaimedItem = command(v.number(), async (id) => {
const { cookies } = getRequestEvent();
verifyJWT(cookies);
const reponse = await sql`
UPDATE items
SET claimed_date = null
WHERE id = ${id};
`;
});

View File

@ -1,9 +1,8 @@
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import sql from '$lib/db/db.server'; import sql from '$lib/db/db.server';
import type { Item } from '$lib/types/item';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { type inquiryTokenPayload, Sender } from '$lib/types/inquiries'; import { type inquiryTokenPayload, Sender } from '$lib/types/inquiries';
import { threadId } from 'node:worker_threads'; import type { Item } from '$lib/types/item';
// Create a transporter object using SMTP transport // Create a transporter object using SMTP transport
export const transporter = nodemailer.createTransport({ export const transporter = nodemailer.createTransport({
@ -47,7 +46,7 @@ export async function sendNewInquiryEmail(inquiryId: number) {
// //
// `; // `;
const item: Item = await sql` const [item]: Item[] = await sql`
SELECT SELECT
i.*, i.*,
json_agg( json_agg(
@ -57,7 +56,7 @@ export async function sendNewInquiryEmail(inquiryId: number) {
'created_at', t.created_at, 'created_at', t.created_at,
'messages', m.messages 'messages', m.messages
) )
) FILTER (WHERE t.id = ${threadId}) AS threads ) FILTER ( WHERE t.id = ${inquiryId} ) AS threads
FROM items i FROM items i
LEFT JOIN inquiry_threads t LEFT JOIN inquiry_threads t
ON t.item_id = i.id ON t.item_id = i.id
@ -77,49 +76,42 @@ export async function sendNewInquiryEmail(inquiryId: number) {
const replyToken = jwt.sign(tokenPayload, process.env.JWT_SECRET!); const replyToken = jwt.sign(tokenPayload, process.env.JWT_SECRET!);
console.log(item); console.log(item);
console.log(item.threads);
console.log(replyToken); console.log(replyToken);
// Send mail with defined transport object // Send mail with defined transport object
// await transporter.sendMail({ await transporter.sendMail({
// from: `Westuffind Notifier <${process.env.EMAIL_USER}>`, from: `Westuffind Notifier <${process.env.EMAIL_USER}>`,
// to: item.emails, to: item.emails,
// // to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING // to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
// replyTo: `${process.env.EMAIL_USER!.split('@')[0]}+${replyToken}${process.env.EMAIL_USER!.split('@')[1]}`, replyTo: `${process.env.EMAIL_USER!.split('@')[0]}+${replyToken}${process.env.EMAIL_USER!.split('@')[1]}`,
// subject: 'New Item Inquiry!', subject: 'New Item Inquiry!',
// text: `Someone has made an inquiry on the item with description: ${item.description}\nThey ask: ${item.threads![0].messages[0].body}\n\n\nRespond to this email directly, or click the below link to reply on Westuffinder\n${process.env.BASE_URL}/items/${item.id}/inquiries/${inquiryId}?token=${replyToken}` text: `Someone has made an inquiry on the item with description: ${item.description}\nThey ask: ${item.threads![0].messages[0].body}\n\n\nRespond to this email directly, or click the below link to reply on Westuffinder\n${process.env.BASE_URL}/items/${item.id}/inquiries/${inquiryId}?token=${replyToken}`
// }); });
} }
export async function sendInquiryMessageEmail(inquiryId: number, sender: Sender) { export async function sendInquiryMessageEmail(inquiryId: number, sender: Sender) {
const item: Item = await sql` const [item]: Item[] = await sql`
SELECT json_agg(item_data) AS result
FROM (
SELECT SELECT
i.*, i.*,
( json_agg(
SELECT json_agg(thread_data) jsonb_build_object(
FROM ( 'id', t.id,
SELECT 'item_id', t.item_id,
it.id, 'created_at', t.created_at,
it.item_id, 'messages', m.messages
(
SELECT json_agg(im)
FROM inquiry_messages im
WHERE im.thread_id = it.id
ORDER BY im.created_at
) AS messages
FROM inquiry_threads it
WHERE it.id = ${inquiryId}
) AS thread_data
) AS threads
FROM items i
WHERE i.id = (
SELECT item_id
FROM inquiry_threads
WHERE id = ${inquiryId}
) )
) AS item_data; ) FILTER ( WHERE t.id = ${inquiryId} ) AS threads
FROM items i
`; LEFT JOIN inquiry_threads t
ON t.item_id = i.id
LEFT JOIN LATERAL (
SELECT
json_agg(im.* ORDER BY im.created_at) AS messages
FROM inquiry_messages im
WHERE im.thread_id = t.id
) m ON TRUE
WHERE i.id = (SELECT item_id FROM inquiry_threads WHERE id = ${inquiryId})
GROUP BY i.id;`;
const tokenPayload: inquiryTokenPayload = { const tokenPayload: inquiryTokenPayload = {
threadId: inquiryId, threadId: inquiryId,
@ -137,3 +129,20 @@ export async function sendInquiryMessageEmail(inquiryId: number, sender: Sender)
text: `Someone has replied to the inquiry on the item with description: ${item.description}\nThey say: ${item.threads![0].messages[item.threads![0].messages.length - 1].body}\n\n\nRespond to this email directly, or click the below link to reply on Westuffinder\n${process.env.BASE_URL}/items/${item.id}/inquiries/${inquiryId}?token=${replyToken}` text: `Someone has replied to the inquiry on the item with description: ${item.description}\nThey say: ${item.threads![0].messages[item.threads![0].messages.length - 1].body}\n\n\nRespond to this email directly, or click the below link to reply on Westuffinder\n${process.env.BASE_URL}/items/${item.id}/inquiries/${inquiryId}?token=${replyToken}`
}); });
} }
export async function sendClaimEmail(id: number, email: string) {
const [item]: Item[] = await sql`
SELECT * FROM items WHERE id = ${id};`;
if (!item.transferred) {
// Send mail with defined transport object
await transporter.sendMail({
from: `Westuffind Notifier <${process.env.EMAIL_USER}>`,
to: item.emails,
replyTo: email,
// to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
subject: 'Your Item was Claimed!',
text: `Someone has claimed your item with description: ${item.description}\nReply to this email explaining how they can pick up the item from you. Replies to this email go directly to the claimer.`
});
}
}

View File

@ -11,10 +11,9 @@
} }
</script> </script>
<section class="relative overflow-hidden"> <section class="relative overflow-hidden" aria-labelledby="hero-heading">
<!-- Hero -->
<div class="mx-auto max-w-6xl px-6 py-24 text-center"> <div class="mx-auto max-w-6xl px-6 py-24 text-center">
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl"> <h1 id="hero-heading" class="text-4xl font-bold tracking-tight sm:text-5xl">
Westuffinder Westuffinder
</h1> </h1>
<p class="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground"> <p class="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">
@ -23,16 +22,24 @@
</p> </p>
<div class="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row"> <div class="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row">
<Button size="lg" class="px-8" onclick={openCreateDialog}> <Button
size="lg"
class="px-8"
onclick={openCreateDialog}
aria-label="Submit a found item"
>
Submit a Found Item Submit a Found Item
</Button> </Button>
<a href="/items" class={cn(buttonVariants({ variant: 'outline', size: 'lg' }), 'px-8')}> <a
href="/items"
class={cn(buttonVariants({ variant: 'outline', size: 'lg' }), 'px-8')}
aria-label="Browse lost items"
>
Browse Lost Items Browse Lost Items
</a> </a>
</div> </div>
</div> </div>
<!-- Subtle background accent -->
<div <div
aria-hidden="true" aria-hidden="true"
class="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl" class="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl"
@ -43,37 +50,59 @@
</div> </div>
</section> </section>
<section class="mx-auto max-w-6xl px-6 py-20"> <section class="mx-auto max-w-6xl px-6 py-20" aria-labelledby="info-cards-heading">
<h2 id="info-cards-heading" class="sr-only">Information</h2>
<div class="grid gap-8 md:grid-cols-3"> <div class="grid gap-8 md:grid-cols-3">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Found an Item?</CardTitle> <CardTitle id="found-item-heading">Found an Item?</CardTitle>
</CardHeader> </CardHeader>
<CardContent class="space-y-3 text-sm text-muted-foreground"> <CardContent
class="space-y-3 text-sm text-muted-foreground"
aria-labelledby="found-item-heading"
>
<p> <p>
Turn it in digitally in less than a minute. Add a description and where you found it. Turn it in digitally in less than a minute. Add a description and where you found it.
</p> </p>
<Button class="mt-2 w-full" onclick={openCreateDialog}>Submit Item</Button> <Button
class="mt-2 w-full"
onclick={openCreateDialog}
aria-label="Submit a found item"
>
Submit Item
</Button>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Lost Something?</CardTitle> <CardTitle id="lost-item-heading">Lost Something?</CardTitle>
</CardHeader> </CardHeader>
<CardContent class="space-y-3 text-sm text-muted-foreground"> <CardContent
class="space-y-3 text-sm text-muted-foreground"
aria-labelledby="lost-item-heading"
>
<p> <p>
Browse items that have been turned in by students and staff. Browse items that have been turned in by students and staff.
</p> </p>
<a href="/items" class={cn(buttonVariants({ variant: 'outline', size: 'lg' }), 'mt-2 w-full')}>Browse Items</a> <a
href="/items"
class={cn(buttonVariants({ variant: 'outline', size: 'lg' }), 'mt-2 w-full')}
aria-label="Browse found items"
>
Browse Items
</a>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Safe &amp; School-Run</CardTitle> <CardTitle id="safe-heading">Safe &amp; School-Run</CardTitle>
</CardHeader> </CardHeader>
<CardContent class="space-y-3 text-sm text-muted-foreground"> <CardContent
class="space-y-3 text-sm text-muted-foreground"
aria-labelledby="safe-heading"
>
<p> <p>
Managed by Waukesha West staff. Items are reviewed before being listed. Managed by Waukesha West staff. Items are reviewed before being listed.
</p> </p>
@ -82,11 +111,16 @@
</div> </div>
</section> </section>
<section class="mx-auto max-w-6xl px-6 pb-24"> <section class="mx-auto max-w-6xl px-6 pb-24" aria-labelledby="staff-heading">
<h2 id="staff-heading" class="sr-only">Staff Access</h2>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Staff only: Staff only:
<a href="/login" class="ml-1 underline underline-offset-4 hover:text-foreground"> <a
href="/login"
class="ml-1 underline underline-offset-4 hover:text-foreground"
aria-label="Admin sign in"
>
Admin sign in Admin sign in
</a> </a>
</p> </p>

View File

@ -0,0 +1,17 @@
import type { PageServerLoad } from '$types';
import sql from '$lib/db/db.server';
import type { User } from '$lib/types/user';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals || !locals.user) {
throw Error('Need to be logged in!');
}
const [userData]: User[] = await sql`
SELECT * FROM users WHERE id = ${locals.user.id}
`;
console.log(userData);
return { userData };
};

View File

@ -1,10 +1,130 @@
<!-- src/routes/account/+page.svelte -->
<script lang="ts"> <script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Separator } from '$lib/components/ui/separator';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { DefaultUserSettings } from '$lib/types/user';
import { enhance } from '$app/forms';
let { data } = $props();
function signOut() { function signOut() {
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
window.location.href = '/'; window.location.href = '/';
} }
let form = $derived({
name: data.userData.name,
email: data.userData.email,
staleItemDays:
data.userData.settings?.staleItemDays ?? DefaultUserSettings.staleItemDays,
notifyAllApprovedInquiries:
data.userData.settings?.notifyAllApprovedInquiries ?? false,
notifyAllTurnedInInquiries:
data.userData.settings?.notifyAllTurnedInInquiries ?? false
});
const formatDate = (date: Date | string) => {
const d = new Date(date);
return d.toLocaleString();
};
</script> </script>
<Button onclick={signOut}>Sign out</Button> <svelte:head>
<title>Account</title>
</svelte:head>
<div class="container mx-auto max-w-3xl py-10">
<Card class="rounded-2xl shadow-sm">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-2xl font-semibold">Account Overview</CardTitle>
<Badge variant="secondary">ID #{data.userData.id}</Badge>
</div>
</CardHeader>
<CardContent class="space-y-6">
<!-- Editable Profile Form -->
<form method="POST" use:enhance class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="username">Username</Label>
<Input id="username" value={data.userData.username} disabled />
</div>
<div class="space-y-2">
<Label for="name">Full Name</Label>
<Input id="name" name="name" bind:value={form.name} />
</div>
<div class="space-y-2 sm:col-span-2">
<Label for="email">Email</Label>
<Input id="email" name="email" type="email" bind:value={form.email} />
</div>
<div class="space-y-2">
<Label for="staleItemDays">Stale Item Days</Label>
<Input
id="staleItemDays"
name="staleItemDays"
type="number"
bind:value={form.staleItemDays}
min="0"
/>
</div>
</div>
<Separator />
<div class="space-y-4">
<h2 class="text-lg font-semibold">Notifications</h2>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">Notify All Approved</p>
<p class="text-sm text-muted-foreground">
Receive notifications when inquiries are approved.
</p>
</div>
<Switch
name="notifyAllApprovedInquiries"
bind:checked={form.notifyAllApprovedInquiries}
/>
</div>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">Notify All Turned In</p>
<p class="text-sm text-muted-foreground">
Receive notifications when inquiries are turned in.
</p>
</div>
<Switch
name="notifyAllTurnedInInquiries"
bind:checked={form.notifyAllTurnedInInquiries}
/>
</div>
</div>
<div class="flex items-center justify-between pt-4">
<div class="text-sm text-muted-foreground">
Member since {formatDate(data.userData.createdAt)} · Last sign in {formatDate(
data.userData.lastSignIn
)}
</div>
<div class="flex gap-2">
<Button type="submit">Save Changes</Button>
<Button type="button" onclick={signOut} variant="destructive">
Sign Out
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
</div>

View File

@ -6,6 +6,7 @@ import sharp from 'sharp';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import type { Item } from '$lib/types/item'; import type { Item } from '$lib/types/item';
import { getFormString, getRequiredFormString } from '$lib/shared'; import { getFormString, getRequiredFormString } from '$lib/shared';
import { sendClaimEmail, sendNewInquiryEmail } from '$lib/email/sender.server';
export const load: PageServerLoad = async ({ url, locals }) => { export const load: PageServerLoad = async ({ url, locals }) => {
const searchQuery = url.searchParams.get('search'); const searchQuery = url.searchParams.get('search');
@ -344,7 +345,7 @@ export const actions: Actions = {
id AS message_id; id AS message_id;
`; `;
// sendNewInquiryEmail(response[0].threadId); await sendNewInquiryEmail(response[0].threadId);
} catch (e) { } catch (e) {
return fail(400, { return fail(400, {
message: e instanceof Error ? e.message : 'Unknown error occurred', message: e instanceof Error ? e.message : 'Unknown error occurred',
@ -359,11 +360,17 @@ export const actions: Actions = {
} }
const data = await request.formData(); const data = await request.formData();
let email: string;
let inquiry: string;
try { try {
inquiry = getRequiredFormString(data, 'inquiry'); email = getRequiredFormString(data, 'email');
const response = await sql`
UPDATE items SET claimed_date = NOW()
WHERE id = ${id};
`;
await sendClaimEmail(id, email);
} catch (e) { } catch (e) {
return fail(400, { return fail(400, {
message: e instanceof Error ? e.message : 'Unknown error occurred', message: e instanceof Error ? e.message : 'Unknown error occurred',

View File

@ -83,7 +83,6 @@
<FieldSeparator class="col-span-full text-lg h-8 my-2"> <FieldSeparator class="col-span-full text-lg h-8 my-2">
Pending items Pending items
</FieldSeparator> </FieldSeparator>
{/if}
{#each data.items as item (item.id)} {#each data.items as item (item.id)}
{#if item.approvedDate === null} {#if item.approvedDate === null}
@ -91,6 +90,7 @@
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} /> inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
{/if} {/if}
{/each} {/each}
{/if}
{#if data.user && data.items.some( {#if data.user && data.items.some(
(item) => item.approvedDate !== null && item.claimedDate === null (item) => item.approvedDate !== null && item.claimedDate === null
@ -101,7 +101,7 @@
{/if} {/if}
{#each data.items as item (item.id)} {#each data.items as item (item.id)}
{#if item.approvedDate !== null} {#if item.approvedDate !== null && item.claimedDate === null}
<ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog} <ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog}
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} /> inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
{/if} {/if}

View File

@ -1,9 +1,11 @@
import type { Actions, PageServerLoad } from '$types'; import type { Actions, PageServerLoad } from '$types';
import { type inquiryTokenPayload, type Message, Sender } from '$lib/types/inquiries'; import { type inquiryTokenPayload, Sender } from '$lib/types/inquiries';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import sql from '$lib/db/db.server'; import sql from '$lib/db/db.server';
import { getRequiredFormString } from '$lib/shared'; import { getRequiredFormString } from '$lib/shared';
import { sendInquiryMessageEmail } from '$lib/email/sender.server';
import type { Item } from '$lib/types/item';
export const load: PageServerLoad = async ({ url, locals, params }) => { export const load: PageServerLoad = async ({ url, locals, params }) => {
const token: string | undefined = url.searchParams.get('token'); const token: string | undefined = url.searchParams.get('token');
@ -11,7 +13,11 @@ export const load: PageServerLoad = async ({ url, locals, params }) => {
const inquiryId: string = params.inquiryId; const inquiryId: string = params.inquiryId;
if (token) { if (token) {
try {
jwt.verify(token, process.env.JWT_SECRET!); jwt.verify(token, process.env.JWT_SECRET!);
} catch {
throw error(403, 'Your response token does not match this inquiry!');
}
} else if (!locals || !locals.user) { } else if (!locals || !locals.user) {
throw error( throw error(
403, 403,
@ -19,11 +25,32 @@ export const load: PageServerLoad = async ({ url, locals, params }) => {
); );
} }
const messages: Message[] = await sql` const [item]: Item[] = await sql`
SELECT * FROM inquiry_messages WHERE thread_id = ${inquiryId} SELECT
`; i.*,
json_agg(
jsonb_build_object(
'id', t.id,
'item_id', t.item_id,
'created_at', t.created_at,
'messages', m.messages
)
) FILTER ( WHERE t.id = ${inquiryId} ) AS threads
FROM items i
LEFT JOIN inquiry_threads t
ON t.item_id = i.id
LEFT JOIN LATERAL (
SELECT
json_agg(im.* ORDER BY im.created_at) AS messages
FROM inquiry_messages im
WHERE im.thread_id = t.id
) m ON TRUE
WHERE i.id = (SELECT item_id FROM inquiry_threads WHERE id = ${inquiryId})
GROUP BY i.id;`;
return { messages }; console.log(item);
return { item };
}; };
export const actions: Actions = { export const actions: Actions = {
@ -37,13 +64,15 @@ export const actions: Actions = {
if (locals && locals.user) { if (locals && locals.user) {
sender = Sender.ADMIN; sender = Sender.ADMIN;
} else if (token) { } else if (token) {
const decoded: inquiryTokenPayload = jwt.verify( let decoded: inquiryTokenPayload;
token, try {
process.env.JWT_SECRET! decoded = jwt.verify(token, process.env.JWT_SECRET!) as inquiryTokenPayload;
) as inquiryTokenPayload;
sender = decoded.sender; sender = decoded.sender;
} catch {
throw error(403, 'Your response token does not match this inquiry!');
}
if (decoded.threadId !== inquiryId) { if (decoded.threadId.toString() !== inquiryId.toString()) {
throw error(403, 'Your response token does not match this inquiry!'); throw error(403, 'Your response token does not match this inquiry!');
} }
} else { } else {
@ -64,6 +93,15 @@ export const actions: Actions = {
RETURNING id; RETURNING id;
`; `;
// TODO: Double check logic
let nextReplySender: Sender;
if (sender === Sender.ADMIN || sender === Sender.FINDER) {
nextReplySender = Sender.INQUIRER;
} else {
nextReplySender = Sender.FINDER;
}
await sendInquiryMessageEmail(response[0].threadId, nextReplySender);
return { success: true }; return { success: true };
} }
} satisfies Actions; } satisfies Actions;

View File

@ -4,6 +4,11 @@
import { Textarea } from '$lib/components/ui/textarea'; import { Textarea } from '$lib/components/ui/textarea';
import { type Message, Sender } from '$lib/types/inquiries'; import { type Message, Sender } from '$lib/types/inquiries';
import SendIcon from '@lucide/svelte/icons/send'; import SendIcon from '@lucide/svelte/icons/send';
import LocationIcon from '@lucide/svelte/icons/map-pinned';
import { page } from '$app/state';
import * as item from 'valibot';
import NoImagePlaceholder from '$lib/components/custom/no-image-placeholder.svelte';
import { Separator } from '$lib/components/ui/separator';
let { data } = $props(); let { data } = $props();
@ -33,10 +38,37 @@
</script> </script>
<div class="max-w-2xl mx-auto p-4"> <div class="max-w-2xl mx-auto p-4">
<div class="flex gap-4">
{#if data.item.image}
<img src="https://fbla26.marinodev.com/uploads/{data.item.id}.jpg"
class="object-cover max-w-48 max-h-48 rounded-2xl"
alt="Lost item">
{:else}
<!-- <div class="min-h-48 w-full bg-accent flex flex-col justify-center">-->
<!-- <div class="justify-center flex ">-->
<!-- <NoImagePlaceholder className="" />-->
<!-- </div>-->
<!-- <p class="text-center mt-4">No image available</p>-->
<!-- </div>-->
{/if}
<div class="">
<div class="flex-1">{data.item.description}</div>
{#if data.item.foundLocation}
<div class="mt-2">
<LocationIcon class="float-left mr-1" size={24} />
<div>{data.item.foundLocation}</div>
</div>
{/if}
</div>
</div>
<Separator class="my-6" />
<div class="relative"> <div class="relative">
<!-- Conversation --> <!-- Conversation -->
<div class="flex flex-col gap-6 relative"> <div class="flex flex-col gap-6 relative">
{#each data.messages as msg, index (msg.id)} {#if data.item.threads}
{#each data.item.threads[0].messages as msg (msg.id)}
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<!-- Avatar --> <!-- Avatar -->
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
@ -44,10 +76,6 @@
class={`flex items-center justify-center w-10 h-10 rounded-full text-white ${senderColor(msg.sender)}`}> class={`flex items-center justify-center w-10 h-10 rounded-full text-white ${senderColor(msg.sender)}`}>
{senderLabel(msg.sender)} {senderLabel(msg.sender)}
</div> </div>
<!-- Timeline line (except for last message) -->
{#if index < data.messages.length - 1}
<div class="w-px flex-1 bg-gray-300 mt-1"></div>
{/if}
</div> </div>
<!-- Message body --> <!-- Message body -->
@ -57,11 +85,12 @@
</div> </div>
</div> </div>
{/each} {/each}
{/if}
</div> </div>
</div> </div>
<!-- Input area --> <!-- Input area -->
<form class="mt-6 flex flex-col gap-2" method="post" action="?/reply"> <form class="mt-6 flex flex-col gap-2" method="post" action="?/reply&token={page.url.searchParams.get('token')}">
<Textarea <Textarea
placeholder="Write a message..." placeholder="Write a message..."
class="resize-none" class="resize-none"

View File

@ -16,39 +16,47 @@
<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">
<form class="flex flex-col gap-6" method="post"> <form class="flex flex-col gap-6" method="post" aria-labelledby="login-heading">
<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 id="login-heading" class="text-2xl font-bold">Login to your account</h1>
<p class="text-muted-foreground text-sm text-balance"> <p id="login-description" 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>. Only admins need to log in.<br>Lost something? Go <a href="/" class="underline text-primary">home</a>.
</p> </p>
</div> </div>
<Field> <Field>
<FieldLabel for="email">Email</FieldLabel> <FieldLabel for="email">Email</FieldLabel>
<Input id="email" type="email" name="email" placeholder="m@example.com" required /> <Input
id="email"
type="email"
name="email"
placeholder="m@example.com"
required
aria-required="true"
/>
</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">-->
<!-- Forgot your password?-->
<!-- </a>-->
</div> </div>
<Input id="password" name="password" type="password" required /> <Input
id="password"
name="password"
type="password"
required
aria-required="true"
aria-describedby={form?.message ? "password-error" : undefined}
aria-invalid={form?.message ? "true" : "false"}
/>
{#if form?.message} {#if form?.message}
<p class="text-error">{form.message}</p> <p id="password-error" class="text-error" role="alert" aria-live="assertive">
{form.message}
</p>
{/if} {/if}
</Field> </Field>
<Field> <Field>
<Button type="submit">Login</Button> <Button type="submit" aria-label="Login to your account">Login</Button>
</Field> </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> </FieldGroup>
</form> </form>
</div> </div>
@ -59,6 +67,7 @@
<img <img
src="login-hero.png" src="login-hero.png"
alt="" alt=""
aria-hidden="true"
class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale" class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
/> />
</div> </div>