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 { type Cookies, error } from '@sveltejs/kit';
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) {
const payload = {

View File

@ -5,7 +5,7 @@
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Label } from '$lib/components/ui/label';
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 LocationIcon from '@lucide/svelte/icons/map-pinned';
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 { approveDenyItem, genDescription } from '$lib/db/items.remote';
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 * as Card from '$lib/components/ui/card';
import { dateFormatOptions } from '$lib/shared';
@ -28,7 +28,13 @@
async function onSelect(file: File) {
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;
}

View File

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

View File

@ -5,7 +5,7 @@
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Label } from '$lib/components/ui/label';
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 LocationIcon from '@lucide/svelte/icons/map-pinned';
import { EMAIL_REGEX_STRING } from '$lib/consts';

View File

@ -1,6 +1,6 @@
<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 LocationIcon from '@lucide/svelte/icons/map-pinned';
import CheckIcon from '@lucide/svelte/icons/check';

View File

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

View File

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

View File

@ -1,4 +1,9 @@
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
export const transporter = nodemailer.createTransport({
@ -11,13 +16,124 @@ export const transporter = nodemailer.createTransport({
}
});
// export async function sendEmail() {
// // Send mail with defined transport object
export async function sendNewInquiryEmail(inquiryId: number) {
// 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({
// from: `Westuffind Notifier <${process.env.EMAIL_USER}>`,
// // to: info.emails.join(', '), // EMAILING OF REAL COMPANIES DISABLED, UNCOMMENT TO ENABLE
// to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
// subject: 'New Application Received!',
// text: `A new application has been received for the posting ''!\n\nCheck it out at ${process.env.BASE_URL}/`
// 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 (
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(payload);
const res = await fetch(
`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) {
console.error(await res.text());
console.log(await res.text());
process.exit(1);
}

View File

@ -55,3 +55,19 @@ export const dateFormatOptions: Intl.DateTimeFormatOptions = {
month: 'short',
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 {
id: number;
// threadId: number;
sender: Sender;
body: string;
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 {
id: number;

View File

@ -19,7 +19,7 @@
<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">
<MdevTriangle size={48} class="text-primary" />
<span class="hidden sm:block">MarinoDev Lost & Found</span>
<span class="hidden sm:block">Westuffinder</span>
</a>
<div class="items-center flex gap-4">
{#if data.user}

View File

@ -15,7 +15,7 @@
<!-- Hero -->
<div class="mx-auto max-w-6xl px-6 py-24 text-center">
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl">
Waukesha West Lost &amp; Found
Westuffinder
</h1>
<p class="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">
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 sharp from 'sharp';
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';
export const load: PageServerLoad = async ({ url, locals }) => {
@ -343,6 +343,8 @@ export const actions: Actions = {
thread_id,
id AS message_id;
`;
// sendNewInquiryEmail(response[0].threadId);
} catch (e) {
return fail(400, {
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 ClaimItemDialog from '$lib/components/custom/claim-item-dialog.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';

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>