inquiries and claims
This commit is contained in:
parent
331765d21e
commit
505434a2d8
@ -3,6 +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';
|
||||||
|
|
||||||
export function setJWTCookie(cookies: Cookies, user: User) {
|
export function setJWTCookie(cookies: Cookies, user: User) {
|
||||||
const payload = {
|
const payload = {
|
||||||
@ -25,6 +26,17 @@ export function setJWTCookie(cookies: Cookies, user: User) {
|
|||||||
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false, secure });
|
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false, secure });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createInquiryToken(payload: inquiryTokenPayload) {
|
||||||
|
if (process.env.JWT_SECRET === undefined) {
|
||||||
|
throw Error('JWT_SECRET not defined');
|
||||||
|
}
|
||||||
|
if (process.env.BASE_URL === undefined) {
|
||||||
|
throw Error('BASE_URL not defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30d' });
|
||||||
|
}
|
||||||
|
|
||||||
export function verifyJWT(cookies: Cookies): UserPayload {
|
export function verifyJWT(cookies: Cookies): UserPayload {
|
||||||
const JWT = cookies.get('jwt');
|
const JWT = cookies.get('jwt');
|
||||||
if (!JWT) throw error(403, 'Unauthorized');
|
if (!JWT) throw error(403, 'Unauthorized');
|
||||||
|
|||||||
82
src/lib/components/custom/claim-item-dialog.svelte
Normal file
82
src/lib/components/custom/claim-item-dialog.svelte
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
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 NoImagePlaceholder from './no-image-placeholder.svelte';
|
||||||
|
import LocationIcon from '@lucide/svelte/icons/map-pinned';
|
||||||
|
import { EMAIL_REGEX_STRING } from '$lib/consts';
|
||||||
|
|
||||||
|
|
||||||
|
let { open = $bindable(), item }: { open: boolean, item: Item | undefined } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Claim Item</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Only submit this if you are sure the item is yours.<br>Claiming the item will remove it from public display.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<form method="post" action="?/inquire">
|
||||||
|
<Field.Group>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
|
||||||
|
{#if item?.image}
|
||||||
|
<img src="https://fbla26.marinodev.com/uploads/{item.id}.jpg"
|
||||||
|
class="object-cover max-w-48 max-h-48 rounded-2xl"
|
||||||
|
alt="Lost item">
|
||||||
|
{:else}
|
||||||
|
<div class="min-h-48 w-full bg-accent flex flex-col justify-center">
|
||||||
|
|
||||||
|
<div class="justify-center flex ">
|
||||||
|
|
||||||
|
<NoImagePlaceholder className="" />
|
||||||
|
</div>
|
||||||
|
<p class="text-center mt-4">No image available</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="inline-block">
|
||||||
|
<div class="flex-1">{item?.description}</div>
|
||||||
|
{#if item?.foundLocation}
|
||||||
|
<div class="mt-2">
|
||||||
|
<LocationIcon class="float-left mr-1" size={24} />
|
||||||
|
<div>{item?.foundLocation}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Field.Field>
|
||||||
|
|
||||||
|
<Field.Label for="email">
|
||||||
|
Your Email <span class="text-error">*</span>
|
||||||
|
</Field.Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="name@domain.com"
|
||||||
|
pattern={EMAIL_REGEX_STRING}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
</Field.Group>
|
||||||
|
|
||||||
|
<Dialog.Footer class="mt-4">
|
||||||
|
<Dialog.Close
|
||||||
|
type="button"
|
||||||
|
class={buttonVariants({ variant: "outline" })}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Close>
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
|
||||||
147
src/lib/components/custom/edit-item-dialog.svelte
Normal file
147
src/lib/components/custom/edit-item-dialog.svelte
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
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 { Separator } from '$lib/components/ui/separator';
|
||||||
|
|
||||||
|
import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte';
|
||||||
|
import { genDescription } from '$lib/db/items.remote';
|
||||||
|
import { EMAIL_REGEX_STRING } from '$lib/consts';
|
||||||
|
import type { Item } from '$lib/types/item.server';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { dateFormatOptions } from '$lib/shared';
|
||||||
|
|
||||||
|
let { open = $bindable(), item = $bindable() }: { open: boolean, item: Item | undefined } = $props();
|
||||||
|
|
||||||
|
|
||||||
|
let itemLocation: string = $derived(item ? item?.transferred ? 'turnedIn' : 'finderPossession' : '');
|
||||||
|
let foundLocation: string | undefined = $derived(item?.foundLocation);
|
||||||
|
let description: string = $derived(item ? item.description : '');
|
||||||
|
let isGenerating = $state(false);
|
||||||
|
|
||||||
|
async function onSelect() {
|
||||||
|
isGenerating = true;
|
||||||
|
description = await genDescription();
|
||||||
|
isGenerating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Content class="max-w-[calc(100%-2rem)] md:max-w-3xl">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Manage Item</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
It will be updated immediately.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<form method="post" action={'?/edit&id=' + item?.id} enctype="multipart/form-data" class="flex-2">
|
||||||
|
|
||||||
|
<Field.Group>
|
||||||
|
<ImageUpload onSelect={onSelect} canRemove={false}
|
||||||
|
previewUrl={'https://fbla26.marinodev.com/uploads/' + item?.id + '.jpg'} />
|
||||||
|
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="description">
|
||||||
|
Description <span class="text-error">*</span>
|
||||||
|
</Field.Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
bind:value={description}
|
||||||
|
placeholder="A red leather book bag..."
|
||||||
|
maxlength={200}
|
||||||
|
rows={3}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="foundLocation">
|
||||||
|
Where was it found?
|
||||||
|
</Field.Label>
|
||||||
|
<Input
|
||||||
|
id="foundLocation"
|
||||||
|
name="foundLocation"
|
||||||
|
bind:value={foundLocation}
|
||||||
|
placeholder="By the tennis courts."
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
|
||||||
|
<RadioGroup.Root name="location" bind:value={itemLocation}>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<RadioGroup.Item value="finderPossession" id="finderPossession" />
|
||||||
|
<Label for="finderPossession">
|
||||||
|
The finder has the item.
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<RadioGroup.Item value="turnedIn" id="turnedIn" />
|
||||||
|
<Label for="turnedIn">
|
||||||
|
The item is in the lost and found.
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Root>
|
||||||
|
|
||||||
|
<Field.Field
|
||||||
|
class={itemLocation !== "finderPossession" ? "hidden pointer-events-none opacity-50" : ""}
|
||||||
|
>
|
||||||
|
<Field.Label for="email">
|
||||||
|
The finder's email <span class="text-error">*</span>
|
||||||
|
</Field.Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="name@domain.com"
|
||||||
|
pattern={EMAIL_REGEX_STRING}
|
||||||
|
required={itemLocation === "finderPossession"}
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
</Field.Group>
|
||||||
|
|
||||||
|
<Dialog.Footer class="mt-4">
|
||||||
|
<Dialog.Close
|
||||||
|
type="button"
|
||||||
|
class={buttonVariants({ variant: "outline" })}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Close>
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
{#if item?.threads}
|
||||||
|
<Separator orientation="vertical" class="mx-4" />
|
||||||
|
<div class="inline-block flex-2">
|
||||||
|
|
||||||
|
<h2 class="text-lg leading-none font-semibold">Item inquiries:</h2>
|
||||||
|
{#each item.threads as thread (thread)}
|
||||||
|
<a href="/items/{item.id}/inquiries/{thread.id}" class="mt-4">
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Date</Card.Title>
|
||||||
|
<Card.Content>Inquirer: {thread.messages[0].body}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Header>
|
||||||
|
</Card.Root>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
|
||||||
|
{#if isGenerating}
|
||||||
|
<div class="fixed inset-0 bg-black/75 z-999999 w-screen h-screen justify-center items-center flex">
|
||||||
|
<p class="text-6xl text-primary">Loading...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@ -7,12 +7,15 @@
|
|||||||
export let required = false;
|
export let required = false;
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
export let onSelect: ((file: File) => void) | null = null;
|
export let onSelect: ((file: File) => void) | null = null;
|
||||||
|
export let canRemove = !required;
|
||||||
|
|
||||||
|
|
||||||
let inputEl: HTMLInputElement | null = null;
|
let inputEl: HTMLInputElement | null = null;
|
||||||
let previewUrl: string | null = null;
|
export let previewUrl: string | null = null;
|
||||||
let dragging = false;
|
let dragging = false;
|
||||||
|
|
||||||
|
console.log(previewUrl);
|
||||||
|
|
||||||
function openFileDialog() {
|
function openFileDialog() {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
inputEl?.click();
|
inputEl?.click();
|
||||||
@ -93,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if previewUrl}
|
{#if previewUrl && canRemove}
|
||||||
<button class="hover:text-destructive p-2" on:click={cleanupPreview} type="button">
|
<button class="hover:text-destructive p-2" on:click={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>
|
||||||
|
|||||||
95
src/lib/components/custom/inquire-item-dialog.svelte
Normal file
95
src/lib/components/custom/inquire-item-dialog.svelte
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
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 NoImagePlaceholder from './no-image-placeholder.svelte';
|
||||||
|
import LocationIcon from '@lucide/svelte/icons/map-pinned';
|
||||||
|
import { EMAIL_REGEX_STRING } from '$lib/consts';
|
||||||
|
|
||||||
|
|
||||||
|
let { open = $bindable(), item }: { open: boolean, item: Item | undefined } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Item Inquiry</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Have a question about an item? Ask here!
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<form method="post" action="?/inquire">
|
||||||
|
<Field.Group>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
|
||||||
|
{#if item?.image}
|
||||||
|
<img src="https://fbla26.marinodev.com/uploads/{item.id}.jpg"
|
||||||
|
class="object-cover max-w-48 max-h-48 rounded-2xl"
|
||||||
|
alt="Lost item">
|
||||||
|
{:else}
|
||||||
|
<div class="min-h-48 w-full bg-accent flex flex-col justify-center">
|
||||||
|
|
||||||
|
<div class="justify-center flex ">
|
||||||
|
|
||||||
|
<NoImagePlaceholder className="" />
|
||||||
|
</div>
|
||||||
|
<p class="text-center mt-4">No image available</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="inline-block">
|
||||||
|
<div class="flex-1">{item?.description}</div>
|
||||||
|
{#if item?.foundLocation}
|
||||||
|
<div class="mt-2">
|
||||||
|
<LocationIcon class="float-left mr-1" size={24} />
|
||||||
|
<div>{item?.foundLocation}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="description">
|
||||||
|
Please describe your inquiry <span class="text-error">*</span>
|
||||||
|
</Field.Label>
|
||||||
|
<Input
|
||||||
|
id="inquiry"
|
||||||
|
name="inquiry"
|
||||||
|
placeholder="Is there a ..."
|
||||||
|
maxlength={200}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
<Field.Field>
|
||||||
|
|
||||||
|
<Field.Label for="email">
|
||||||
|
Your Email <span class="text-error">*</span>
|
||||||
|
</Field.Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="name@domain.com"
|
||||||
|
pattern={EMAIL_REGEX_STRING}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
</Field.Group>
|
||||||
|
|
||||||
|
<Dialog.Footer class="mt-4">
|
||||||
|
<Dialog.Close
|
||||||
|
type="button"
|
||||||
|
class={buttonVariants({ variant: "outline" })}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Close>
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
|
||||||
@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
||||||
import type { Item } from '$lib/types/item';
|
import type { Item } from '$lib/types/item.server';
|
||||||
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';
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||||
|
import NotebookPenIcon from '@lucide/svelte/icons/notebook-pen';
|
||||||
|
import StarIcon from '@lucide/svelte/icons/star';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import { dateFormatOptions } from '$lib/shared';
|
import { dateFormatOptions } from '$lib/shared';
|
||||||
@ -13,17 +15,11 @@
|
|||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import NoImagePlaceholder from './no-image-placeholder.svelte';
|
import NoImagePlaceholder from './no-image-placeholder.svelte';
|
||||||
|
|
||||||
export let item: Item = <Item>{
|
export let item: Item = <Item>{};
|
||||||
id: 2,
|
|
||||||
// title: 'Water Bottle',
|
|
||||||
foundDate: new Date(),
|
|
||||||
approvedDate: new Date(),
|
|
||||||
description: 'A matte black water bottle with a black lid and a "BKLYN BENTO" logo on the side.',
|
|
||||||
transferred: true,
|
|
||||||
foundLocation: 'By the tennis courts.',
|
|
||||||
image: true
|
|
||||||
};
|
|
||||||
export let admin = false;
|
export let admin = false;
|
||||||
|
export let editCallback: (item: Item) => void;
|
||||||
|
export let inquireCallback: (item: Item) => void;
|
||||||
|
export let claimCallback: (item: Item) => void;
|
||||||
|
|
||||||
let timeSincePosted: number | string = (new Date().getTime() - item.foundDate.getTime()) / 1000 / 60 / 60 / 24; // days
|
let timeSincePosted: number | string = (new Date().getTime() - item.foundDate.getTime()) / 1000 / 60 / 60 / 24; // days
|
||||||
if (timeSincePosted < 1) {
|
if (timeSincePosted < 1) {
|
||||||
@ -103,12 +99,25 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button variant="ghost" class="text-edit"
|
<Button variant="ghost" class="text-edit"
|
||||||
onclick={async () => {}}>
|
onclick={() => {editCallback(item)}}>
|
||||||
<PencilIcon />
|
<PencilIcon />
|
||||||
Edit
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-2 justify-between flex">
|
||||||
|
<Button variant="ghost" class="text-edit"
|
||||||
|
onclick={() => {inquireCallback(item)}}>
|
||||||
|
<NotebookPenIcon />
|
||||||
|
Inquire
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" class="text-primary"
|
||||||
|
onclick={() => {claimCallback(item)}}>
|
||||||
|
<StarIcon />
|
||||||
|
Claim
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
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';
|
||||||
import { EMAIL_REGEX_STRING } from '$lib/consts';
|
import { EMAIL_REGEX_STRING } from '$lib/consts';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
|
|
||||||
let itemLocation: string | undefined = $state('');
|
let itemLocation: string | undefined = $state('');
|
||||||
let foundLocation: string | undefined = $state();
|
let foundLocation: string | undefined = $state();
|
||||||
@ -41,12 +42,13 @@
|
|||||||
<Field.Label for="description">
|
<Field.Label for="description">
|
||||||
Description <span class="text-error">*</span>
|
Description <span class="text-error">*</span>
|
||||||
</Field.Label>
|
</Field.Label>
|
||||||
<Input
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
name="description"
|
name="description"
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder="A red leather book bag..."
|
placeholder="A red leather book bag..."
|
||||||
maxlength={200}
|
maxlength={200}
|
||||||
|
rows={3}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
@ -60,7 +62,6 @@
|
|||||||
name="foundLocation"
|
name="foundLocation"
|
||||||
bind:value={foundLocation}
|
bind:value={foundLocation}
|
||||||
placeholder="By the tennis courts."
|
placeholder="By the tennis courts."
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
@ -84,7 +85,7 @@
|
|||||||
class={itemLocation !== "finderPossession" ? "hidden pointer-events-none opacity-50" : ""}
|
class={itemLocation !== "finderPossession" ? "hidden pointer-events-none opacity-50" : ""}
|
||||||
>
|
>
|
||||||
<Field.Label for="email">
|
<Field.Label for="email">
|
||||||
Your Email
|
Your Email <span class="text-error">*</span>
|
||||||
</Field.Label>
|
</Field.Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
|
|||||||
@ -12,11 +12,8 @@ export const genDescription = query(async () => {
|
|||||||
export const approveDenyItem = query(
|
export const approveDenyItem = query(
|
||||||
v.object({ id: v.number(), approved: v.boolean() }),
|
v.object({ id: v.number(), approved: v.boolean() }),
|
||||||
async ({ id, approved }) => {
|
async ({ id, approved }) => {
|
||||||
console.log('called');
|
|
||||||
|
|
||||||
const { cookies } = getRequestEvent();
|
const { cookies } = getRequestEvent();
|
||||||
const userPayload = verifyJWT(cookies);
|
const userPayload = verifyJWT(cookies);
|
||||||
console.log('1');
|
|
||||||
|
|
||||||
if (approved) {
|
if (approved) {
|
||||||
const reponse = await sql`
|
const reponse = await sql`
|
||||||
@ -35,7 +32,6 @@ export const approveDenyItem = query(
|
|||||||
)
|
)
|
||||||
WHERE id = ${id};
|
WHERE id = ${id};
|
||||||
`;
|
`;
|
||||||
console.log(reponse);
|
|
||||||
} else {
|
} else {
|
||||||
await sql`
|
await sql`
|
||||||
DELETE FROM items WHERE id = ${id};
|
DELETE FROM items WHERE id = ${id};
|
||||||
|
|||||||
@ -4,10 +4,22 @@ export enum Sender {
|
|||||||
INQUIRER = 'inquirer'
|
INQUIRER = 'inquirer'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface message {
|
export interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
threadId: number;
|
// threadId: number;
|
||||||
sender: Sender;
|
sender: Sender;
|
||||||
body: string;
|
body: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Thread {
|
||||||
|
id: number;
|
||||||
|
messages: Message[];
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface inquiryTokenPayload {
|
||||||
|
sender: Sender;
|
||||||
|
threadId: number;
|
||||||
|
messageId?: number;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { Thread } from '$lib/types/inquiries.server';
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
id: number;
|
id: number;
|
||||||
emails?: string[];
|
emails?: string[];
|
||||||
@ -12,4 +14,5 @@ export interface Item {
|
|||||||
foundLocation: string;
|
foundLocation: string;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
image: boolean;
|
image: boolean;
|
||||||
|
threads?: Thread[];
|
||||||
}
|
}
|
||||||
@ -7,7 +7,7 @@
|
|||||||
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
|
|
||||||
|
|
||||||
let { children } = $props();
|
let { children, data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ModeWatcher />
|
<ModeWatcher />
|
||||||
@ -22,11 +22,14 @@
|
|||||||
<span class="hidden sm:block">MarinoDev Lost & Found</span>
|
<span class="hidden sm:block">MarinoDev Lost & Found</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="items-center flex gap-4">
|
<div class="items-center flex gap-4">
|
||||||
|
{#if data.user}
|
||||||
|
|
||||||
<div class="inline-block">
|
<div class="inline-block">
|
||||||
<a href="/account" class={buttonVariants({variant: 'outline'})}>
|
<a href="/account" class={buttonVariants({variant: 'outline'})}>
|
||||||
Account
|
Account
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="inline-block">
|
<div class="inline-block">
|
||||||
<Button onclick={toggleMode} variant="outline" size="icon">
|
<Button onclick={toggleMode} variant="outline" size="icon">
|
||||||
<SunIcon
|
<SunIcon
|
||||||
|
|||||||
10
src/routes/account/+page.svelte
Normal file
10
src/routes/account/+page.svelte
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
|
function signOut() {
|
||||||
|
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button onclick={signOut}>Sign out</Button>
|
||||||
@ -4,26 +4,89 @@ 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';
|
import type { Item } from '$lib/types/item.server';
|
||||||
|
import type { Message } from '$lib/types/inquiries.server';
|
||||||
|
import { Sender } from '$lib/types/inquiries.server';
|
||||||
import { getFormString, getRequiredFormString } from '$lib/shared';
|
import { getFormString, getRequiredFormString } from '$lib/shared';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||||
const searchQuery = url.searchParams.get('search') as string;
|
const searchQuery = url.searchParams.get('search') as string;
|
||||||
console.log(searchQuery);
|
|
||||||
|
|
||||||
|
// If the user is logged in, fetch items together with their threads (each thread contains its first message)
|
||||||
|
if (locals && locals.user) {
|
||||||
|
try {
|
||||||
|
type DBMessage = {
|
||||||
|
id: number;
|
||||||
|
sender: keyof typeof Sender | string;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
type DBThread = { id: number; messages: DBMessage[] };
|
||||||
|
type RowsItem = { threads: DBThread[]; [key: string]: unknown };
|
||||||
|
|
||||||
|
const rows = (await sql`
|
||||||
|
SELECT i.*, COALESCE(
|
||||||
|
(
|
||||||
|
SELECT json_agg(json_build_object('id', t.id, 'messages', json_build_array(json_build_object('id', m.id, 'sender', m.sender, 'body', m.body, 'createdAt', m.created_at))) ORDER BY t.id)
|
||||||
|
FROM inquiry_threads t
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT id, sender, body, created_at
|
||||||
|
FROM inquiry_messages
|
||||||
|
WHERE thread_id = t.id
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
) m ON true
|
||||||
|
WHERE t.item_id = i.id
|
||||||
|
), '[]'::json
|
||||||
|
) AS threads
|
||||||
|
FROM items i
|
||||||
|
${
|
||||||
|
searchQuery
|
||||||
|
? sql`WHERE word_similarity(${searchQuery}, i.description) > 0.3
|
||||||
|
ORDER BY word_similarity(${searchQuery}, i.description)`
|
||||||
|
: sql``
|
||||||
|
};
|
||||||
|
`) as RowsItem[];
|
||||||
|
|
||||||
|
// `rows` contains items with a `threads` JSON column. Attach parsed threads to each item.
|
||||||
|
const items: Item[] = rows.map((r) => {
|
||||||
|
const item: Record<string, unknown> = { ...r };
|
||||||
|
const rawThreads = (r.threads || []) as DBThread[];
|
||||||
|
item.threads = rawThreads.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
messages: (t.messages || []).map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
// coerce sender to our Sender enum type if possible
|
||||||
|
sender: (Object.values(Sender).includes(m.sender as Sender)
|
||||||
|
? (m.sender as Sender)
|
||||||
|
: (m.sender as unknown)) as Message['sender'],
|
||||||
|
body: m.body,
|
||||||
|
createdAt: new Date(m.createdAt)
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
return item as unknown as Item;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load items with threads:', err);
|
||||||
|
// Fallback to non-joined fetch so the page still loads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not logged in or fallback: simple item list (no threads)
|
||||||
let items: Item[];
|
let items: Item[];
|
||||||
if (!searchQuery) {
|
if (!searchQuery) {
|
||||||
items = await sql`SELECT * FROM items;`;
|
items = await sql`SELECT * FROM items;`;
|
||||||
} else {
|
} else {
|
||||||
items = await sql`
|
items = await sql`
|
||||||
SELECT * FROM items
|
SELECT * FROM items
|
||||||
WHERE levenshtein(description, ${searchQuery}) <= 3
|
WHERE word_similarity(${searchQuery}, description) > 0.3
|
||||||
ORDER BY levenshtein(description, ${searchQuery})
|
ORDER BY word_similarity(${searchQuery}, description);
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
items
|
return { items };
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
@ -128,5 +191,139 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
},
|
||||||
|
edit: async ({ request, url }) => {
|
||||||
|
const idParam = url.searchParams.get('id');
|
||||||
|
if (!idParam) {
|
||||||
|
return fail(400, { message: 'Missing id for edit action', success: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(idParam);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) {
|
||||||
|
return fail(400, { message: 'Invalid id provided', success: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
|
||||||
|
let description: string;
|
||||||
|
let foundLocation: string | null;
|
||||||
|
let email: string | null;
|
||||||
|
let location: string | null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
description = getRequiredFormString(data, 'description');
|
||||||
|
foundLocation = getFormString(data, 'foundLocation');
|
||||||
|
email = getFormString(data, 'email');
|
||||||
|
location = getFormString(data, 'location');
|
||||||
|
|
||||||
|
if (!email && location !== 'turnedIn') {
|
||||||
|
return fail(400, {
|
||||||
|
message: "Email is required if it is still in finder's possession",
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = data.get('image');
|
||||||
|
if (file instanceof File && file.size === 0) {
|
||||||
|
file = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputBuffer: Buffer | undefined;
|
||||||
|
if (file) {
|
||||||
|
console.log(file);
|
||||||
|
if (!(file instanceof File) || file.size === 0) {
|
||||||
|
return fail(400, { message: 'No file uploaded or file is invalid', success: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert File → Buffer
|
||||||
|
const inputBuffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
const image = sharp(inputBuffer);
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
|
||||||
|
if (metadata.format === 'jpeg') {
|
||||||
|
outputBuffer = inputBuffer;
|
||||||
|
} else {
|
||||||
|
outputBuffer = await image.jpeg({ quality: 90 }).toBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and run UPDATE query. Only touch `image` column when a new file was uploaded.
|
||||||
|
const response = await sql`
|
||||||
|
UPDATE items SET
|
||||||
|
description = ${description},
|
||||||
|
transferred = ${location === 'turnedIn'},
|
||||||
|
found_location = ${foundLocation}
|
||||||
|
${file ? sql`,image = ${file instanceof File}` : sql``}
|
||||||
|
WHERE id = ${id}
|
||||||
|
RETURNING id;
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (outputBuffer) {
|
||||||
|
try {
|
||||||
|
const savePath = path.join('uploads', `${response[0]['id']}.jpg`);
|
||||||
|
writeFileSync(savePath, outputBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('File upload failed:', err);
|
||||||
|
return fail(500, { message: 'Internal server error during file upload', success: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return fail(400, {
|
||||||
|
message: e instanceof Error ? e.message : 'Unknown error occurred',
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
inquire: async ({ request, url }) => {
|
||||||
|
const id = url.searchParams.get('id');
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { message: 'Missing id!', success: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
|
||||||
|
let inquiry: string;
|
||||||
|
let email: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
inquiry = getRequiredFormString(data, 'inquiry');
|
||||||
|
email = getRequiredFormString(data, 'email');
|
||||||
|
|
||||||
|
const response = sql`
|
||||||
|
WITH new_thread AS (
|
||||||
|
INSERT INTO inquiry_threads (item_id) VALUES (${id})
|
||||||
|
RETURNING id
|
||||||
|
) INSERT INTO inquiry_messages (thread_id, sender, body)
|
||||||
|
VALUES (new_thread.id, 'inquirer', ${inquiry})
|
||||||
|
RETURNING new_thread.id, id;
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
return fail(400, {
|
||||||
|
message: e instanceof Error ? e.message : 'Unknown error occurred',
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
claim: async ({ request, url }) => {
|
||||||
|
const id = url.searchParams.get('id');
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { message: 'Missing id!', success: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
|
||||||
|
let inquiry: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
inquiry = getRequiredFormString(data, 'inquiry');
|
||||||
|
} catch (e) {
|
||||||
|
return fail(400, {
|
||||||
|
message: e instanceof Error ? e.message : 'Unknown error occurred',
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
|
|||||||
@ -12,68 +12,108 @@
|
|||||||
import ItemListing from '$lib/components/custom/item-listing.svelte';
|
import ItemListing from '$lib/components/custom/item-listing.svelte';
|
||||||
import { FieldSeparator } from '$lib/components/ui/field';
|
import { FieldSeparator } from '$lib/components/ui/field';
|
||||||
import SubmitItemDialog from '$lib/components/custom/submit-item-dialog.svelte';
|
import SubmitItemDialog from '$lib/components/custom/submit-item-dialog.svelte';
|
||||||
|
import EditItemDialog from '$lib/components/custom/edit-item-dialog.svelte';
|
||||||
|
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';
|
||||||
|
|
||||||
|
|
||||||
let createDialogOpen: boolean = $state(false);
|
let createDialogOpen: boolean = $state(false);
|
||||||
|
|
||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
createDialogOpen = true;
|
createDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let editDialogOpen: boolean = $state(false);
|
||||||
|
let editItem: Item | undefined = $state(undefined);
|
||||||
|
|
||||||
|
function openEditDialog(item: Item | undefined) {
|
||||||
|
editItem = item;
|
||||||
|
editDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inquireDialogOpen: boolean = $state(false);
|
||||||
|
let inquireItem: Item | undefined = $state(undefined);
|
||||||
|
|
||||||
|
function openInquireDialog(item: Item | undefined) {
|
||||||
|
inquireItem = item;
|
||||||
|
inquireDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let claimDialogOpen: boolean = $state(false);
|
||||||
|
let claimItem: Item | undefined = $state(undefined);
|
||||||
|
|
||||||
|
function openClaimDialog(item: Item | undefined) {
|
||||||
|
claimItem = item;
|
||||||
|
claimDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4">
|
<div class="max-w-7xl mx-auto px-4">
|
||||||
<div class="justify-between flex">
|
<div class="justify-between flex mb-4">
|
||||||
<form action="">
|
<form action="" class="w-full">
|
||||||
<Field.Field orientation="horizontal" class="max-w-sm">
|
<Field.Field orientation="horizontal" class="w-full max-w-md">
|
||||||
<Input type="search" name="search" id="search" placeholder="Search..." />
|
<Input type="search" name="search" id="search" placeholder="Search..." />
|
||||||
<Button variant="default" type="submit">Search</Button>
|
<Button variant="default" type="submit">Search</Button>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
</form>
|
</form>
|
||||||
<!-- <h1 class="text-3xl mb-2 mt-2">Found Items</h1>-->
|
<!-- <h1 class="text-3xl mb-2 mt-2">Found Items</h1>-->
|
||||||
<div class="inline-block">
|
<div class="hidden md:inline-block">
|
||||||
<Button variant="secondary" class="" onclick={openCreateDialog}>
|
<Button variant="default" class="" onclick={openCreateDialog}>
|
||||||
Submit a Found Item
|
Submit a found item
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 grid-cols-[repeat(auto-fill,minmax(16rem,max-content))]">
|
<div class="grid gap-4 grid-cols-[repeat(auto-fill,minmax(16rem,max-content))] justify-center">
|
||||||
|
|
||||||
{#if data.user && data.items.some(
|
{#if data.user && data.items.some(
|
||||||
(item) => item.approvedDate === null
|
(item) => item.approvedDate === null
|
||||||
)}
|
)}
|
||||||
<FieldSeparator class="col-span-full text-lg h-8">
|
<FieldSeparator class="col-span-full text-lg h-8 my-2">
|
||||||
Pending items
|
Pending items
|
||||||
</FieldSeparator>
|
</FieldSeparator>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each data.items as item (item.id)}
|
{#each data.items as item (item.id)}
|
||||||
{#if item.approvedDate === null}
|
{#if item.approvedDate === null}
|
||||||
<ItemListing item={item} admin={data.user !== null} />
|
<ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog}
|
||||||
|
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if data.user && data.items.some(
|
{#if data.user && data.items.some(
|
||||||
(item) => item.approvedDate !== null
|
(item) => item.approvedDate !== null
|
||||||
)}
|
)}
|
||||||
<FieldSeparator class="col-span-full text-lg my-2 h-8">
|
<FieldSeparator class="col-span-full text-lg my-2 h-8 w-full">
|
||||||
Public items
|
Public items
|
||||||
</FieldSeparator>
|
</FieldSeparator>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each data.items as item (item.id)}
|
{#each data.items as item (item.id)}
|
||||||
{#if item.approvedDate !== null}
|
{#if item.approvedDate !== null}
|
||||||
<ItemListing item={item} admin={data.user !== null} />
|
<ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog}
|
||||||
|
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button class="fixed shadow-lg bottom-6 right-6 rounded-xl md:hidden p-6 text-xl" size="default"
|
||||||
|
onclick={openCreateDialog}>
|
||||||
|
<PlusIcon size={32} />
|
||||||
|
New item
|
||||||
|
</Button>
|
||||||
|
|
||||||
<SubmitItemDialog bind:open={createDialogOpen} />
|
<SubmitItemDialog bind:open={createDialogOpen} />
|
||||||
|
<EditItemDialog bind:open={editDialogOpen} item={editItem} />
|
||||||
|
<InquireItemDialog bind:open={inquireDialogOpen} item={inquireItem} />
|
||||||
|
<ClaimItemDialog bind:open={claimDialogOpen} item={claimItem} />
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user