Lots of dev
ci / docker_image (push) Successful in 2m55s
ci / deploy (push) Successful in 51s

This commit is contained in:
2026-02-03 00:23:43 -06:00
parent 2c2b08aa1a
commit 4ea6549ac7
66 changed files with 2125 additions and 172 deletions
+6 -6
View File
@@ -1,4 +1,4 @@
import type { User } from '$lib/types';
import type { User } from '$lib/types/user';
import bcrypt from 'bcrypt';
import sql from '$lib/db/db.server';
import type { Cookies } from '@sveltejs/kit';
@@ -49,22 +49,22 @@ export function setJWT(cookies: Cookies, user: User) {
export async function login(email: string, password: string): Promise<User> {
try {
const [user] = await sql`
SELECT id, email, password_hash, perms, name
const [user]: User[] = await sql`
SELECT * FROM users
WHERE email = ${email};
`;
if (await bcrypt.compare(password, user.password_hash)) {
if (await bcrypt.compare(password, user.password_hash!)) {
delete user.password_hash;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
sql`
UPDATE users
SET last_signin = NOW()
SET last_sign_in = NOW()
WHERE id = ${user.id};
`;
return <User>user;
return user;
}
} catch {
throw Error('Error signing in ');
@@ -0,0 +1,166 @@
<script lang="ts">
import ImagePlus from '@lucide/svelte/icons/image-plus';
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;
let inputEl: HTMLInputElement | null = null;
let previewUrl: string | null = null;
let dragging = false;
function openFileDialog() {
if (!disabled) {
inputEl?.click();
}
}
function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const selected = files[0];
if (!selected.type.startsWith('image/')) return;
cleanupPreview();
previewUrl = URL.createObjectURL(selected);
// Trigger callback
if (onSelect) onSelect(selected);
}
function onInputChange(e: Event) {
const target = e.target as HTMLInputElement;
handleFiles(target.files);
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragging = false;
if (disabled) return;
handleFiles(e.dataTransfer?.files ?? null);
}
function cleanupPreview() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
previewUrl = null;
}
}
onDestroy(cleanupPreview);
</script>
<div>
<input
bind:this={inputEl}
type="file"
name={name}
accept="image/*"
required={required}
disabled={disabled}
capture="environment"
on:change={onInputChange}
hidden
/>
<button
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}
type="button"
>
{#if previewUrl}
<img src={previewUrl} alt="Selected preview" />
<div class="overlay">
<ImagePlus size={24} />
<span>Replace image</span>
</div>
{:else}
<div class="placeholder py-4">
<ImagePlus size={32} />
<p>Click or drag an image here <span class="text-error">{required ? '*' : ''}</span></p>
</div>
{/if}
</button>
{#if previewUrl}
<button class="hover:text-destructive p-2" on:click={cleanupPreview} type="button">
<X size={24} class="inline" />
<span class="inline align-middle">Remove image</span>
</button>
{/if}
</div>
<style>
.dropzone {
position: relative;
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;
justify-content: center;
overflow: hidden;
transition: border-color 0.2s, background 0.2s;
}
.dropzone.dragging {
border-color: var(--primary);
background: var(--muted);
}
.placeholder {
text-align: center;
color: var(--muted-foreground);
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
color: white;
display: flex;
flex-direction: column;
gap: 0.4rem;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
text-align: center;
font-size: 0.9rem;
}
.has-image:hover .overlay {
opacity: 1;
}
.has-image:hover img {
filter: brightness(0.8);
}
</style>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { Item } from '$lib/types/item';
import { Badge } from '$lib/components/ui/badge';
import LocationIcon from '@lucide/svelte/icons/map-pinned';
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.'
};
export let admin = false;
</script>
<a href="items/{item.id}"
class="bg-card text-card-foreground flex flex-col gap-2 rounded-xl border shadow-sm max-w-sm overflow-hidden min-2-3xs">
<img src="https://fbla26.marinodev.com/uploads/{item.id}.jpg" alt="" class="object-cover max-h-48">
<div class="px-2 pb-2">
<div>
<!-- <div class="font-bold inline-block">{item.title}</div>-->
<!-- <div class="inline-block">-->
{#if item.transferred}
<Badge variant="secondary" class="float-right">In Lost & Found</Badge>
{:else}
<Badge variant="outline" class="float-right">With Finder</Badge>
{/if}
<div>{item.description}</div>
{#if item.foundLocation}
<div class="pt-2">
<LocationIcon class="float-left mr-1" size={24} />
<div>{item.foundLocation}</div>
</div>
{/if}
</div>
</div>
</a>
-50
View File
@@ -1,50 +0,0 @@
<script lang="ts">
import {
FieldGroup,
Field,
FieldLabel,
FieldDescription
} from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLFormAttributes } from 'svelte/elements';
let {
class: className,
...restProps
}: WithElementRef<HTMLFormAttributes> = $props();
</script>
<form class={cn("flex flex-col gap-6", className)} {...restProps}>
<FieldGroup>
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-2xl font-bold">Login to your account</h1>
<p class="text-muted-foreground text-sm text-balance">
Enter your email below to login to your account
</p>
</div>
<Field>
<FieldLabel for="email">Email</FieldLabel>
<Input id="email" type="email" placeholder="m@example.com" required />
</Field>
<Field>
<div class="flex items-center">
<FieldLabel for="password">Password</FieldLabel>
<a href="##" class="ms-auto text-sm underline-offset-4 hover:underline">
Forgot your password?
</a>
</div>
<Input id="password" type="password" required />
</Field>
<Field>
<Button type="submit">Login</Button>
</Field>
<Field>
<FieldDescription class="text-center">
Don't have an account?
<a href="/signup" class="underline underline-offset-4">Sign up</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
-49
View File
@@ -1,49 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import type { HTMLAttributes } from 'svelte/elements';
let { class: className, ...restProps }: HTMLAttributes<HTMLFormElement> = $props();
</script>
<form class={cn("flex flex-col gap-6", className)} {...restProps}>
<Field.Group>
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-2xl font-bold">Create your account</h1>
<p class="text-muted-foreground text-sm text-balance">
Fill in the form below to create your account
</p>
</div>
<Field.Field>
<Field.Label for="name">Full Name</Field.Label>
<Input id="name" type="text" placeholder="John Doe" required />
</Field.Field>
<Field.Field>
<Field.Label for="email">Email</Field.Label>
<Input id="email" type="email" placeholder="m@example.com" required />
<Field.Description>
We'll use this to contact you. We will not share your email with anyone else.
</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label for="password">Password</Field.Label>
<Input id="password" type="password" required />
<Field.Description>Must be at least 8 characters long.</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label for="confirm-password">Confirm Password</Field.Label>
<Input id="confirm-password" type="password" required />
<Field.Description>Please confirm your password.</Field.Description>
</Field.Field>
<Field.Field>
<Button type="submit">Create Account</Button>
</Field.Field>
<Field.Field>
<Field.Description class="px-6 text-center">
Already have an account? <a href="/login">Sign in</a>
</Field.Description>
</Field.Field>
</Field.Group>
</form>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>
+2
View File
@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
@@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>
+6
View File
@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
@@ -0,0 +1,45 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import DialogPortal from './dialog-portal.svelte';
import XIcon from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"max-h-[calc(100%-2rem)] overflow-y-auto bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-lg leading-none font-semibold", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />
+34
View File
@@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};
@@ -0,0 +1,10 @@
import Root from "./radio-group.svelte";
import Item from "./radio-group-item.svelte";
export {
Root,
Item,
//
Root as RadioGroup,
Item as RadioGroupItem,
};
@@ -0,0 +1,31 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
</script>
<RadioGroupPrimitive.Item
bind:ref
data-slot="radio-group-item"
class={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<div data-slot="radio-group-indicator" class="relative flex items-center justify-center">
{#if checked}
<CircleIcon
class="fill-primary absolute start-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2"
/>
{/if}
</div>
{/snippet}
</RadioGroupPrimitive.Item>
@@ -0,0 +1,19 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: RadioGroupPrimitive.RootProps = $props();
</script>
<RadioGroupPrimitive.Root
bind:ref
bind:value
data-slot="radio-group"
class={cn("grid gap-3", className)}
{...restProps}
/>
+37
View File
@@ -0,0 +1,37 @@
import Root from "./select.svelte";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
import Portal from "./select-portal.svelte";
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
};
@@ -0,0 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />
@@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>
@@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />
+7
View File
@@ -0,0 +1,7 @@
import Root from "./textarea.svelte";
export {
Root,
//
Root as Textarea,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLTextareaAttributes } from "svelte/elements";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
"data-slot": dataSlot = "textarea",
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script>
<textarea
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
bind:value
{...restProps}
></textarea>
+19
View File
@@ -7,3 +7,22 @@ export const PERMISSIONS = {
};
export const EXPIRE_REMINDER_DAYS = 30;
// const EMAIL_REGEX = new RegExp(
// // eslint-disable-next-line no-control-regex
// "([!#-'*+/-9=?A-Z^-~-]+(\.[!#-'*+/-9=?A-Z^-~-]+)*|\"\(\[\]!#-[^-~ \t]|(\\[\t -~]))+\")@([!#-'*+/-9=?A-Z^-~-]+(\.[!#-'*+/-9=?A-Z^-~-]+)*|\[[\t -Z^-~]*])"
// );
// const EMAIL_REGEX = new RegExp(
// /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
// );
// const EMAIL_REGEX =
// // eslint-disable-next-line no-control-regex
// /(?:[a-z0-9!#$%&'*+\/=?^`\{-\}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`\{-\}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/;
const EMAIL_REGEX =
/^(?!\.)(?!.*\.\.)([a-z0-9_'+\-.]*)[a-z0-9_'+-]@([a-z0-9][a-z0-9-]*\.)+[a-z]{2,}$/i;
// Replace single quote with HTML entity or remove it from the character class
export const EMAIL_REGEX_STRING = EMAIL_REGEX.source.replace(/'/g, '&#39;');
+2 -1
View File
@@ -8,7 +8,8 @@ const sql = postgres({
port: parseInt(process.env.POSTGRES_PORT!),
database: process.env.POSTGRES_DB,
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD
password: process.env.POSTGRES_PASSWORD,
transform: postgres.camel
});
export default sql;
-67
View File
@@ -1,67 +0,0 @@
import type { User } from '$lib/types';
import sql from '$lib/db/db.server';
import bcrypt from 'bcrypt';
// should require MANAGE_USERS permission
export async function getUsers(searchQuery: string | null = null): Promise<User[]> {
return sql`
SELECT id,
email,
perms,
name,
created_at AT TIME ZONE 'UTC' AS "createdAt",
last_signin AT TIME ZONE 'UTC' AS "lastSignIn"
FROM users
WHERE (${!searchQuery ? sql`TRUE` : sql`email ILIKE ${'%' + searchQuery + '%'} OR name ILIKE ${'%' + searchQuery + '%'}`});
`;
}
export async function getUser(id: number): Promise<User> {
return <User>(
await sql`
SELECT id,
email,
perms,
name,
created_at AT TIME ZONE 'UTC' AS "createdAt",
last_signin AT TIME ZONE 'UTC' AS "lastSignIn"
FROM users
WHERE (id = ${id}) LIMIT 1;
`
)[0];
}
export async function createUser(user: User) {
const passwordHash = await bcrypt.hash(user.password!, 12);
await sql`
INSERT INTO users (email, password_hash, perms, name, created_at, last_signin)
VALUES (${user.email}, ${passwordHash}, ${user.perms}, ${user.name}, NOW(), NOW())
RETURNING id;
`;
}
export async function editUser(user: User) {
let passwordHash: string | undefined;
if (user.password) {
passwordHash = await bcrypt.hash(user.password, 12);
}
await sql`
UPDATE users
SET
email = ${user.email},
${passwordHash ? sql`password_hash = ${passwordHash},` : sql``}
perms = ${user.perms},
name = ${user.name}
WHERE id = ${user.id!}
RETURNING id;
`;
}
export async function deleteUser(id: number) {
await sql`
DELETE FROM users
WHERE id = ${id}
`;
}
+23
View File
@@ -0,0 +1,23 @@
import nodemailer from 'nodemailer';
// Create a transporter object using SMTP transport
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: Number(process.env.EMAIL_PORT),
secure: true, // true for 465, false for other ports
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
export async function sendEmployerNotificationEmail() {
// Send mail with defined transport object
await transporter.sendMail({
from: `CareerConnect Notifications <${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}/`
});
}
+11 -4
View File
@@ -1,4 +1,5 @@
import type { User } from '$lib/types';
import type { User } from '$lib/types/user';
import { fail } from '@sveltejs/kit';
export const getCookieValue = (name: string): string =>
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
@@ -21,15 +22,21 @@ export function getFormString(data: FormData, key: string): string | undefined {
}
if (typeof value !== 'string') {
throw Error(`Incorrect input in field ${key}.`);
throw fail(400, {
error: `Incorrect input in field ${key}.`,
success: false
});
}
return value.trim();
}
export function getRequiredFormString(data: FormData, key: string) {
const value = data.get(key);
if (typeof value !== 'string') {
throw Error(`Missing required field ${key}.`);
if (typeof value !== 'string' || value === '') {
throw fail(400, {
error: `Missing required field ${key}.`,
success: false
});
}
return value.trim();
}
-22
View File
@@ -1,22 +0,0 @@
export interface User {
id: number;
email: string;
phone: string;
password?: string;
name: string;
createdAt: Date;
lastSignIn: Date;
}
export interface Item {
id: number;
ownerEmail: string;
ownerPhone: string;
foundDate: Date;
approvedDate?: Date;
claimedDate?: Date;
title: string;
description: string;
transferred: boolean; // to L&F location
keywords?: string[];
}
+13
View File
@@ -0,0 +1,13 @@
export enum Sender {
ADMIN = 'admin',
FINDER = 'finder',
INQUIRER = 'inquirer'
}
export interface message {
id: number;
threadId: number;
sender: Sender;
body: string;
createdAt: Date;
}
+13
View File
@@ -0,0 +1,13 @@
export interface Item {
id: number;
emails?: string[];
// ownerPhone: string;
foundDate: Date;
approvedDate?: Date;
claimedDate?: Date;
// title: string;
description: string;
transferred: boolean; // to L&F location
keywords?: string[];
foundLocation: string;
}
+30
View File
@@ -0,0 +1,30 @@
export interface User {
id: number;
username: string;
email: string;
name: string;
password?: string;
password_hash?: string;
settings?: UserSettings;
createdAt: Date;
lastSignIn: Date;
}
export interface UserPayload {
id: number;
email: string;
username: string;
name: string;
}
export interface UserSettings {
staleItemDays: number;
notifyAllApprovedInquiries?: boolean;
notifyAllTurnedInInquiries?: boolean;
}
export const DefaultUserSettings: UserSettings = {
staleItemDays: 30,
notifyAllApprovedInquiries: false,
notifyAllTurnedInInquiries: false
};