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 { 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;
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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}`
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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}
|
||||
|
||||
@ -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 & Found
|
||||
Westuffinder
|
||||
</h1>
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">
|
||||
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 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',
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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