dev
This commit is contained in:
parent
28968c680c
commit
ee80f849bd
@ -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 = {
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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 & 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 isn’t yours?
|
Lost something at school? Found something that isn’t yours?
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
75
src/routes/items/[itemId]/inquiries/[inquiryId]/+page.svelte
Normal file
75
src/routes/items/[itemId]/inquiries/[inquiryId]/+page.svelte
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user