dev
All checks were successful
ci / docker_image (push) Successful in 3m42s
ci / deploy (push) Successful in 28s

This commit is contained in:
Drake Marino 2026-02-06 22:39:15 -06:00
parent 28968c680c
commit ee80f849bd
20 changed files with 343 additions and 49 deletions

View File

@ -3,7 +3,7 @@ import bcrypt from 'bcrypt';
import sql from '$lib/db/db.server'; import sql from '$lib/db/db.server';
import { type Cookies, error } from '@sveltejs/kit'; import { type Cookies, error } from '@sveltejs/kit';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import type { inquiryTokenPayload } from '$lib/types/inquiries.server'; import type { inquiryTokenPayload } from '$lib/types/inquiries';
export function setJWTCookie(cookies: Cookies, user: User) { export function setJWTCookie(cookies: Cookies, user: User) {
const payload = { const payload = {

View File

@ -5,7 +5,7 @@
import * as RadioGroup from '$lib/components/ui/radio-group'; import * as RadioGroup from '$lib/components/ui/radio-group';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import * as Field from '$lib/components/ui/field'; import * as Field from '$lib/components/ui/field';
import type { Item } from '$lib/types/item.server'; import type { Item } from '$lib/types/item';
import NoImagePlaceholder from './no-image-placeholder.svelte'; import NoImagePlaceholder from './no-image-placeholder.svelte';
import LocationIcon from '@lucide/svelte/icons/map-pinned'; import LocationIcon from '@lucide/svelte/icons/map-pinned';
import { EMAIL_REGEX_STRING } from '$lib/consts'; import { EMAIL_REGEX_STRING } from '$lib/consts';

View File

@ -10,7 +10,7 @@
import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte'; import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte';
import { approveDenyItem, genDescription } from '$lib/db/items.remote'; import { approveDenyItem, genDescription } from '$lib/db/items.remote';
import { EMAIL_REGEX_STRING } from '$lib/consts'; import { EMAIL_REGEX_STRING } from '$lib/consts';
import type { Item } from '$lib/types/item.server'; import type { Item } from '$lib/types/item';
import { Textarea } from '$lib/components/ui/textarea'; import { Textarea } from '$lib/components/ui/textarea';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { dateFormatOptions } from '$lib/shared'; import { dateFormatOptions } from '$lib/shared';
@ -28,7 +28,13 @@
async function onSelect(file: File) { async function onSelect(file: File) {
isGenerating = true; isGenerating = true;
description = await genDescription(file);
const buffer = await file.arrayBuffer();
const base64 = btoa(
String.fromCharCode(...new Uint8Array(buffer))
);
description = await genDescription(base64);
isGenerating = false; isGenerating = false;
} }

View File

@ -3,17 +3,25 @@
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
export let name = 'image'; let {
export let required = false; name = 'image',
export let disabled = false; required = false,
export let onSelect: ((file: File) => void) | null = null; disabled = false,
export let canRemove = !required; onSelect = null,
previewUrl = null
}: {
name?: string;
required?: boolean;
disabled?: boolean;
onSelect?: ((file: File) => void) | null;
previewUrl?: string | null;
} = $props();
let inputEl = $state<HTMLInputElement | null>(null);
let dragging = $state(false);
let inputEl: HTMLInputElement | null = null; // derived value instead of initializing from required
export let previewUrl: string | null = null; let canRemove = $derived(!required);
let dragging = false;
function openFileDialog() { function openFileDialog() {
if (!disabled) { if (!disabled) {
@ -22,6 +30,7 @@
} }
function handleFiles(files: FileList | null) { function handleFiles(files: FileList | null) {
console.log('handleFiles');
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
const selected = files[0]; const selected = files[0];
@ -30,7 +39,6 @@
cleanupPreview(); cleanupPreview();
previewUrl = URL.createObjectURL(selected); previewUrl = URL.createObjectURL(selected);
// Trigger callback
if (onSelect) onSelect(selected); if (onSelect) onSelect(selected);
} }
@ -56,9 +64,7 @@
onDestroy(cleanupPreview); onDestroy(cleanupPreview);
</script> </script>
<div> <div>
<input <input
bind:this={inputEl} bind:this={inputEl}
type="file" type="file"
@ -67,7 +73,7 @@
required={required} required={required}
disabled={disabled} disabled={disabled}
capture="environment" capture="environment"
on:change={onInputChange} onchange={onInputChange}
formaction="/item?/describe" formaction="/item?/describe"
hidden hidden
/> />
@ -76,10 +82,10 @@
class="dropzone" class="dropzone"
class:has-image={!!previewUrl} class:has-image={!!previewUrl}
class:dragging={dragging} class:dragging={dragging}
on:click={openFileDialog} onclick={openFileDialog}
on:dragover|preventDefault={() => !disabled && (dragging = true)} ondragover={(e) => {e.preventDefault(); dragging = !disabled;}}
on:dragleave={() => (dragging = false)} ondragleave={() => (dragging = false)}
on:drop={onDrop} ondrop={onDrop}
type="button" type="button"
> >
{#if previewUrl} {#if previewUrl}
@ -92,12 +98,20 @@
{:else} {:else}
<div class="placeholder py-4"> <div class="placeholder py-4">
<ImagePlus size={32} /> <ImagePlus size={32} />
<p>Click or drag an image here <span class="text-error">{required ? '*' : ''}</span></p> <p>
Click or drag an image here
<span class="text-error">{required ? '*' : ''}</span>
</p>
</div> </div>
{/if} {/if}
</button> </button>
{#if previewUrl && canRemove} {#if previewUrl && canRemove}
<button class="hover:text-destructive p-2" on:click={cleanupPreview} type="button"> <button
class="hover:text-destructive p-2"
onclick={cleanupPreview}
type="button"
>
<X size={24} class="inline" /> <X size={24} class="inline" />
<span class="inline align-middle">Remove image</span> <span class="inline align-middle">Remove image</span>
</button> </button>
@ -110,10 +124,8 @@
width: 100%; width: 100%;
max-height: 200px; max-height: 200px;
min-height: 80px; min-height: 80px;
/*height: 200px;*/
border-radius: 12px; border-radius: 12px;
border: 2px dashed var(--border); border: 2px dashed var(--border);
/*background: var(--bg, #fafafa);*/
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -5,7 +5,7 @@
import * as RadioGroup from '$lib/components/ui/radio-group'; import * as RadioGroup from '$lib/components/ui/radio-group';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import * as Field from '$lib/components/ui/field'; import * as Field from '$lib/components/ui/field';
import type { Item } from '$lib/types/item.server'; import type { Item } from '$lib/types/item';
import NoImagePlaceholder from './no-image-placeholder.svelte'; import NoImagePlaceholder from './no-image-placeholder.svelte';
import LocationIcon from '@lucide/svelte/icons/map-pinned'; import LocationIcon from '@lucide/svelte/icons/map-pinned';
import { EMAIL_REGEX_STRING } from '$lib/consts'; import { EMAIL_REGEX_STRING } from '$lib/consts';

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Item } from '$lib/types/item.server'; 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 CheckIcon from '@lucide/svelte/icons/check';

View File

@ -5,6 +5,7 @@
import * as RadioGroup from '$lib/components/ui/radio-group'; import * as RadioGroup from '$lib/components/ui/radio-group';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import * as Field from '$lib/components/ui/field'; import * as Field from '$lib/components/ui/field';
import { fileToBase64 } from '$lib/shared';
import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte'; import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte';
import { genDescription } from '$lib/db/items.remote'; import { genDescription } from '$lib/db/items.remote';
@ -19,10 +20,7 @@
async function onSelect(file: File) { async function onSelect(file: File) {
isGenerating = true; isGenerating = true;
const buffer = await file.arrayBuffer(); const base64 = await fileToBase64(file);
const base64 = btoa(
String.fromCharCode(...new Uint8Array(buffer))
);
description = await genDescription(base64); description = await genDescription(base64);
isGenerating = false; isGenerating = false;

View File

@ -16,7 +16,7 @@ export const genDescription = command(v.string(), async (data) => {
const outputBuffer = await image const outputBuffer = await image
.rotate() .rotate()
.resize({ fit: 'outside', height: 400, width: 400 }) .resize({ fit: 'outside', height: 300, width: 300 })
.jpeg({ quality: 80 }) // adjust if needed .jpeg({ quality: 80 }) // adjust if needed
.toBuffer(); .toBuffer();

View File

@ -1,4 +1,9 @@
import nodemailer from 'nodemailer'; 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';
// Create a transporter object using SMTP transport // Create a transporter object using SMTP transport
export const transporter = nodemailer.createTransport({ export const transporter = nodemailer.createTransport({
@ -11,13 +16,124 @@ export const transporter = nodemailer.createTransport({
} }
}); });
// export async function sendEmail() { export async function sendNewInquiryEmail(inquiryId: number) {
// // Send mail with defined transport object // const item: Item = await sql`
// SELECT json_agg(item_data) AS result
// FROM (
// 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
// ) 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;
//
// `;
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 = ${threadId}) 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,
sender: Sender.FINDER
};
const replyToken = jwt.sign(tokenPayload, process.env.JWT_SECRET!);
console.log(item);
console.log(replyToken);
// 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: info.emails.join(', '), // EMAILING OF REAL COMPANIES DISABLED, UNCOMMENT TO ENABLE // to: item.emails,
// to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING // // to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
// subject: 'New Application Received!', // replyTo: `${process.env.EMAIL_USER!.split('@')[0]}+${replyToken}${process.env.EMAIL_USER!.split('@')[1]}`,
// text: `A new application has been received for the posting ''!\n\nCheck it out at ${process.env.BASE_URL}/` // 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 (
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}
)
) AS item_data;
`;
const tokenPayload: inquiryTokenPayload = {
threadId: inquiryId,
sender
};
const replyToken = jwt.sign(tokenPayload, process.env.JWT_SECRET!);
// 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 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}`
});
}

View File

@ -22,6 +22,7 @@ export async function LLMDescribe(imageData: string) {
}; };
console.log('AIing it'); console.log('AIing it');
console.log(payload);
const res = await fetch( const res = await fetch(
`http://${process.env.LLAMA_HOST!}:${process.env.LLAMA_PORT!}/v1/chat/completions`, `http://${process.env.LLAMA_HOST!}:${process.env.LLAMA_PORT!}/v1/chat/completions`,
@ -35,7 +36,7 @@ export async function LLMDescribe(imageData: string) {
); );
if (!res.ok) { if (!res.ok) {
console.error(await res.text()); console.log(await res.text());
process.exit(1); process.exit(1);
} }

View File

@ -55,3 +55,19 @@ export const dateFormatOptions: Intl.DateTimeFormatOptions = {
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
}; };
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// result is like: "data:image/jpeg;base64,AAAA..."
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}

View File

@ -6,7 +6,6 @@ export enum Sender {
export interface Message { export interface Message {
id: number; id: number;
// threadId: number;
sender: Sender; sender: Sender;
body: string; body: string;
createdAt: Date; createdAt: Date;

View File

@ -1,4 +1,4 @@
import type { Thread } from '$lib/types/inquiries.server'; import type { Thread } from '$lib/types/inquiries';
export interface Item { export interface Item {
id: number; id: number;

View File

@ -19,7 +19,7 @@
<header class="flex justify-between items-center max-w-7xl w-screen p-4"> <header class="flex justify-between items-center max-w-7xl w-screen p-4">
<a href="/" class="flex items-center gap-2 text-2xl font-bold"> <a href="/" class="flex items-center gap-2 text-2xl font-bold">
<MdevTriangle size={48} class="text-primary" /> <MdevTriangle size={48} class="text-primary" />
<span class="hidden sm:block">MarinoDev Lost & Found</span> <span class="hidden sm:block">Westuffinder</span>
</a> </a>
<div class="items-center flex gap-4"> <div class="items-center flex gap-4">
{#if data.user} {#if data.user}

View File

@ -15,7 +15,7 @@
<!-- Hero --> <!-- 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 class="text-4xl font-bold tracking-tight sm:text-5xl">
Waukesha West Lost &amp; Found 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">
Lost something at school? Found something that isnt yours? Lost something at school? Found something that isnt yours?

View File

@ -4,7 +4,7 @@ import path from 'path';
import sql from '$lib/db/db.server'; import sql from '$lib/db/db.server';
import sharp from 'sharp'; import sharp from 'sharp';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import type { Item } from '$lib/types/item.server'; import type { Item } from '$lib/types/item';
import { getFormString, getRequiredFormString } from '$lib/shared'; import { getFormString, getRequiredFormString } from '$lib/shared';
export const load: PageServerLoad = async ({ url, locals }) => { export const load: PageServerLoad = async ({ url, locals }) => {
@ -343,6 +343,8 @@ export const actions: Actions = {
thread_id, thread_id,
id AS message_id; id AS message_id;
`; `;
// 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',

View File

@ -16,7 +16,7 @@
import InquireItemDialog from '$lib/components/custom/inquire-item-dialog.svelte'; import InquireItemDialog from '$lib/components/custom/inquire-item-dialog.svelte';
import ClaimItemDialog from '$lib/components/custom/claim-item-dialog.svelte'; import ClaimItemDialog from '$lib/components/custom/claim-item-dialog.svelte';
import { PlusIcon } from '@lucide/svelte'; import { PlusIcon } from '@lucide/svelte';
import type { Item } from '$lib/types/item.server'; import type { Item } from '$lib/types/item';
// import { type Item } from '$lib/types/item'; // import { type Item } from '$lib/types/item';

View File

@ -0,0 +1,69 @@
import type { Actions, PageServerLoad } from '$types';
import { type inquiryTokenPayload, type Message, 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';
export const load: PageServerLoad = async ({ url, locals, params }) => {
const token: string | undefined = url.searchParams.get('token');
// const itemId: string = params.itemId;
const inquiryId: string = params.inquiryId;
if (token) {
jwt.verify(token, process.env.JWT_SECRET!);
} else if (!locals || !locals.user) {
throw error(
403,
'You must be either signed in, or have a respond token to access this inquiry!'
);
}
const messages: Message[] = await sql`
SELECT * FROM inquiry_messages WHERE thread_id = ${inquiryId}
`;
return { messages };
};
export const actions: Actions = {
reply: async ({ request, url, locals, params }) => {
const token: string | undefined = url.searchParams.get('token');
const itemId: number = params.itemId;
const inquiryId: number = params.inquiryId;
let sender: Sender | undefined;
if (locals && locals.user) {
sender = Sender.ADMIN;
} else if (token) {
const decoded: inquiryTokenPayload = jwt.verify(
token,
process.env.JWT_SECRET!
) as inquiryTokenPayload;
sender = decoded.sender;
if (decoded.threadId !== inquiryId) {
throw error(403, 'Your response token does not match this inquiry!');
}
} else {
throw error(
403,
'You must be either signed in, or have a respond token to access this inquiry!'
);
}
const data = await request.formData();
const body = getRequiredFormString(data, 'message');
console.log(inquiryId, sender, body);
const response = await sql`
INSERT INTO inquiry_messages (thread_id, sender, body)
VALUES (${inquiryId}, ${sender}, ${body})
RETURNING id;
`;
return { success: true };
}
} satisfies Actions;

View File

@ -0,0 +1,75 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import { type Message, Sender } from '$lib/types/inquiries';
import SendIcon from '@lucide/svelte/icons/send';
let { data } = $props();
// Optional: Generate a simple label/icon for the sender
const senderLabel = (sender: Sender) => {
switch (sender) {
case Sender.ADMIN:
return 'A';
case Sender.FINDER:
return 'F';
case Sender.INQUIRER:
return 'I';
}
};
const senderColor = (sender: Sender) => {
switch (sender) {
case Sender.ADMIN:
return 'bg-red-500';
case Sender.FINDER:
return 'bg-blue-500';
case Sender.INQUIRER:
return 'bg-green-500';
}
};
</script>
<div class="max-w-2xl mx-auto p-4">
<div class="relative">
<!-- Conversation -->
<div class="flex flex-col gap-6 relative">
{#each data.messages as msg, index (msg.id)}
<div class="flex items-start gap-4">
<!-- Avatar -->
<div class="flex flex-col items-center">
<div
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 -->
<div class="bg-card rounded-lg p-3 shadow-sm max-w-xl">
<p class="text-sm">{msg.body}</p>
<span class="text-xs text-muted-foreground mt-1 block">{msg.createdAt.toLocaleString()}</span>
</div>
</div>
{/each}
</div>
</div>
<!-- Input area -->
<form class="mt-6 flex flex-col gap-2" method="post" action="?/reply">
<Textarea
placeholder="Write a message..."
class="resize-none"
name="message"
rows={3}
/>
<Button class="self-end" type="submit">Send
<SendIcon />
</Button>
</form>
</div>