dev
ci / docker_image (push) Successful in 3m42s
ci / deploy (push) Successful in 28s

This commit is contained in:
2026-02-06 22:39:15 -06:00
parent 28968c680c
commit ee80f849bd
20 changed files with 343 additions and 49 deletions
+1 -1
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 = {
@@ -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';
@@ -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;
}
@@ -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;
@@ -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';
@@ -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';
@@ -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;
+1 -1
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();
+126 -10
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
// 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}/`
// });
// }
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: 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}`
});
}
+2 -1
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);
}
+16
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);
});
}
@@ -6,7 +6,6 @@ export enum Sender {
export interface Message {
id: number;
// threadId: number;
sender: Sender;
body: string;
createdAt: Date;
@@ -1,4 +1,4 @@
import type { Thread } from '$lib/types/inquiries.server';
import type { Thread } from '$lib/types/inquiries';
export interface Item {
id: number;