Compare commits
3 Commits
ee80f849bd
...
3955f95830
| Author | SHA1 | Date | |
|---|---|---|---|
| 3955f95830 | |||
| 60b5705107 | |||
| cd128d7914 |
17
.env.example
Normal file
17
.env.example
Normal 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
|
||||
91
README.md
91
README.md
@ -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
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
git clone https://git.marinodev.com/MarinoDev/FBLA25
|
||||
cd FBLA25
|
||||
```
|
||||
|
||||
## 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
|
||||
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
|
||||
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.
|
||||
|
||||
@ -34,9 +34,9 @@ services:
|
||||
- ./llm-models:/models:ro
|
||||
command:
|
||||
- -m
|
||||
- /models/Qwen3VL-2B-Instruct-Q4_K_M.gguf
|
||||
- /models/${LLAMA_MODEL}
|
||||
- --mmproj
|
||||
- /models/mmproj-Qwen3VL-2B-Instruct-Q8_0.gguf
|
||||
- /models/${LLAMA_MMPROJ}
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --port
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form method="post" action="?/claim">
|
||||
<form method="post" action="?/claim&id={item?.id}">
|
||||
<Field.Group>
|
||||
<div class="flex gap-4">
|
||||
|
||||
|
||||
@ -7,11 +7,13 @@
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import NotebookPenIcon from '@lucide/svelte/icons/notebook-pen';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import StarIcon from '@lucide/svelte/icons/star';
|
||||
import ArchiveRestoreIcon from '@lucide/svelte/icons/archive-restore';
|
||||
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 { approveDenyItem, restoreClaimedItem } from '$lib/db/items.remote';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import NoImagePlaceholder from './no-image-placeholder.svelte';
|
||||
|
||||
@ -98,11 +100,26 @@
|
||||
Deny
|
||||
</Button>
|
||||
{/if}
|
||||
{#if item.claimedDate === null}
|
||||
<Button variant="ghost" class="text-action"
|
||||
onclick={() => {editCallback(item)}}>
|
||||
<PencilIcon />
|
||||
Manage
|
||||
</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>
|
||||
{:else}
|
||||
|
||||
7
src/lib/components/ui/switch/index.ts
Normal file
7
src/lib/components/ui/switch/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import Root from "./switch.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Switch,
|
||||
};
|
||||
29
src/lib/components/ui/switch/switch.svelte
Normal file
29
src/lib/components/ui/switch/switch.svelte
Normal 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>
|
||||
@ -25,4 +25,5 @@ const EMAIL_REGEX =
|
||||
/^(?!\.)(?!.*\.\.)([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
|
||||
export const EMAIL_REGEX_STRING = EMAIL_REGEX.source.replace(/'/g, ''');
|
||||
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,}$";
|
||||
|
||||
@ -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};
|
||||
`;
|
||||
});
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import sql from '$lib/db/db.server';
|
||||
import type { Item } from '$lib/types/item';
|
||||
import jwt from 'jsonwebtoken';
|
||||
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
|
||||
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
|
||||
i.*,
|
||||
json_agg(
|
||||
@ -57,7 +56,7 @@ export async function sendNewInquiryEmail(inquiryId: number) {
|
||||
'created_at', t.created_at,
|
||||
'messages', m.messages
|
||||
)
|
||||
) FILTER (WHERE t.id = ${threadId}) AS threads
|
||||
) FILTER ( WHERE t.id = ${inquiryId} ) AS threads
|
||||
FROM items i
|
||||
LEFT JOIN inquiry_threads t
|
||||
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!);
|
||||
|
||||
console.log(item);
|
||||
console.log(item.threads);
|
||||
console.log(replyToken);
|
||||
// Send mail with defined transport object
|
||||
// await transporter.sendMail({
|
||||
// from: `Westuffind Notifier <${process.env.EMAIL_USER}>`,
|
||||
// to: item.emails,
|
||||
// // to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
|
||||
// replyTo: `${process.env.EMAIL_USER!.split('@')[0]}+${replyToken}${process.env.EMAIL_USER!.split('@')[1]}`,
|
||||
// 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}`
|
||||
// });
|
||||
await transporter.sendMail({
|
||||
from: `Westuffind Notifier <${process.env.EMAIL_USER}>`,
|
||||
to: item.emails,
|
||||
// to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
|
||||
replyTo: `${process.env.EMAIL_USER!.split('@')[0]}+${replyToken}${process.env.EMAIL_USER!.split('@')[1]}`,
|
||||
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}`
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendInquiryMessageEmail(inquiryId: number, sender: Sender) {
|
||||
const item: Item = await sql`
|
||||
SELECT json_agg(item_data) AS result
|
||||
FROM (
|
||||
const [item]: Item[] = await sql`
|
||||
SELECT
|
||||
i.*,
|
||||
(
|
||||
SELECT json_agg(thread_data)
|
||||
FROM (
|
||||
SELECT
|
||||
it.id,
|
||||
it.item_id,
|
||||
(
|
||||
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}
|
||||
json_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'item_id', t.item_id,
|
||||
'created_at', t.created_at,
|
||||
'messages', m.messages
|
||||
)
|
||||
) 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 = {
|
||||
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}`
|
||||
});
|
||||
}
|
||||
|
||||
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.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,10 +11,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="relative overflow-hidden">
|
||||
<!-- Hero -->
|
||||
<section class="relative overflow-hidden" aria-labelledby="hero-heading">
|
||||
<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
|
||||
</h1>
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">
|
||||
@ -23,16 +22,24 @@
|
||||
</p>
|
||||
|
||||
<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
|
||||
</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
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtle background accent -->
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl"
|
||||
@ -43,37 +50,59 @@
|
||||
</div>
|
||||
</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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Found an Item?</CardTitle>
|
||||
<CardTitle id="found-item-heading">Found an Item?</CardTitle>
|
||||
</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>
|
||||
Turn it in digitally in less than a minute. Add a description and where you found it.
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lost Something?</CardTitle>
|
||||
<CardTitle id="lost-item-heading">Lost Something?</CardTitle>
|
||||
</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>
|
||||
Browse items that have been turned in by students and staff.
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Safe & School-Run</CardTitle>
|
||||
<CardTitle id="safe-heading">Safe & School-Run</CardTitle>
|
||||
</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>
|
||||
Managed by Waukesha West staff. Items are reviewed before being listed.
|
||||
</p>
|
||||
@ -82,11 +111,16 @@
|
||||
</div>
|
||||
</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">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
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
|
||||
</a>
|
||||
</p>
|
||||
|
||||
17
src/routes/account/+page.server.ts
Normal file
17
src/routes/account/+page.server.ts
Normal 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 };
|
||||
};
|
||||
@ -1,10 +1,130 @@
|
||||
<!-- src/routes/account/+page.svelte -->
|
||||
<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 { DefaultUserSettings } from '$lib/types/user';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
function signOut() {
|
||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
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>
|
||||
|
||||
<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>
|
||||
@ -6,6 +6,7 @@ import sharp from 'sharp';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import type { Item } from '$lib/types/item';
|
||||
import { getFormString, getRequiredFormString } from '$lib/shared';
|
||||
import { sendClaimEmail, sendNewInquiryEmail } from '$lib/email/sender.server';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
const searchQuery = url.searchParams.get('search');
|
||||
@ -344,7 +345,7 @@ export const actions: Actions = {
|
||||
id AS message_id;
|
||||
`;
|
||||
|
||||
// sendNewInquiryEmail(response[0].threadId);
|
||||
await sendNewInquiryEmail(response[0].threadId);
|
||||
} catch (e) {
|
||||
return fail(400, {
|
||||
message: e instanceof Error ? e.message : 'Unknown error occurred',
|
||||
@ -359,11 +360,17 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
const data = await request.formData();
|
||||
|
||||
let inquiry: string;
|
||||
let email: string;
|
||||
|
||||
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) {
|
||||
return fail(400, {
|
||||
message: e instanceof Error ? e.message : 'Unknown error occurred',
|
||||
|
||||
@ -83,7 +83,6 @@
|
||||
<FieldSeparator class="col-span-full text-lg h-8 my-2">
|
||||
Pending items
|
||||
</FieldSeparator>
|
||||
{/if}
|
||||
|
||||
{#each data.items as item (item.id)}
|
||||
{#if item.approvedDate === null}
|
||||
@ -91,6 +90,7 @@
|
||||
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if data.user && data.items.some(
|
||||
(item) => item.approvedDate !== null && item.claimedDate === null
|
||||
@ -101,7 +101,7 @@
|
||||
{/if}
|
||||
|
||||
{#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}
|
||||
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
|
||||
{/if}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
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 { error } from '@sveltejs/kit';
|
||||
import sql from '$lib/db/db.server';
|
||||
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 }) => {
|
||||
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;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
jwt.verify(token, process.env.JWT_SECRET!);
|
||||
} catch {
|
||||
throw error(403, 'Your response token does not match this inquiry!');
|
||||
}
|
||||
} else if (!locals || !locals.user) {
|
||||
throw error(
|
||||
403,
|
||||
@ -19,11 +25,32 @@ export const load: PageServerLoad = async ({ url, locals, params }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const messages: Message[] = await sql`
|
||||
SELECT * FROM inquiry_messages WHERE thread_id = ${inquiryId}
|
||||
`;
|
||||
const [item]: Item[] = await sql`
|
||||
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 = {
|
||||
@ -37,13 +64,15 @@ export const actions: Actions = {
|
||||
if (locals && locals.user) {
|
||||
sender = Sender.ADMIN;
|
||||
} else if (token) {
|
||||
const decoded: inquiryTokenPayload = jwt.verify(
|
||||
token,
|
||||
process.env.JWT_SECRET!
|
||||
) as inquiryTokenPayload;
|
||||
let decoded: inquiryTokenPayload;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET!) as inquiryTokenPayload;
|
||||
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!');
|
||||
}
|
||||
} else {
|
||||
@ -64,6 +93,15 @@ export const actions: Actions = {
|
||||
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 };
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
||||
@ -4,6 +4,11 @@
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { type Message, Sender } from '$lib/types/inquiries';
|
||||
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();
|
||||
|
||||
@ -33,10 +38,37 @@
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<!-- Conversation -->
|
||||
<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">
|
||||
<!-- Avatar -->
|
||||
<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)}`}>
|
||||
{senderLabel(msg.sender)}
|
||||
</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>
|
||||
|
||||
<!-- Message body -->
|
||||
@ -57,11 +85,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
placeholder="Write a message..."
|
||||
class="resize-none"
|
||||
|
||||
@ -16,39 +16,47 @@
|
||||
<div class="flex-1"></div>
|
||||
<div class="justify-center flex">
|
||||
<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>
|
||||
<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">
|
||||
<h1 id="login-heading" class="text-2xl font-bold">Login to your account</h1>
|
||||
<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>.
|
||||
</p>
|
||||
</div>
|
||||
<Field>
|
||||
<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>
|
||||
<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 />
|
||||
<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}
|
||||
<p class="text-error">{form.message}</p>
|
||||
<p id="password-error" class="text-error" role="alert" aria-live="assertive">
|
||||
{form.message}
|
||||
</p>
|
||||
{/if}
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit">Login</Button>
|
||||
<Button type="submit" aria-label="Login to your account">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>
|
||||
@ -59,6 +67,7 @@
|
||||
<img
|
||||
src="login-hero.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user