Compare commits

...
45 Commits
Author SHA1 Message Date
drake 60ad76d48a raise new item and increase height
ci / docker_image (push) Successful in 3m24s
ci / deploy (push) Successful in 1m2s
2026-07-01 13:02:21 -05:00
drake 38fe82731e t to i
ci / docker_image (push) Successful in 2m15s
ci / deploy (push) Successful in 30s
2026-06-30 13:12:56 -05:00
drake 5cce7928ec ordering
ci / docker_image (push) Successful in 2m17s
ci / deploy (push) Successful in 26s
2026-06-30 13:05:01 -05:00
drake 6f375cd990 propagation
ci / docker_image (push) Successful in 2m38s
ci / deploy (push) Successful in 29s
2026-06-30 12:57:04 -05:00
drake 681b15e793 autofocus
ci / docker_image (push) Successful in 2m21s
ci / deploy (push) Successful in 31s
2026-06-30 12:43:04 -05:00
drake 53a75f6573 ux updates
ci / docker_image (push) Successful in 3m0s
ci / deploy (push) Successful in 1m5s
2026-06-30 12:19:06 -05:00
drake eb40444e46 db fixed
ci / docker_image (push) Successful in 2m22s
ci / deploy (push) Successful in 29s
2026-06-29 17:09:32 -05:00
drake 1b179f9d16 downscale pre-ai
ci / docker_image (push) Successful in 2m28s
ci / deploy (push) Successful in 31s
2026-06-29 16:21:40 -05:00
drake 7e6e31aa83 updates before nlc
ci / docker_image (push) Successful in 2m55s
ci / deploy (push) Successful in 29s
2026-06-29 14:43:12 -05:00
drake ee19bd9dea round 2
ci / docker_image (push) Successful in 2m9s
ci / deploy (push) Successful in 25s
2026-04-13 03:45:02 +00:00
drake aa501b4c16 apostrophe
ci / deploy (push) Blocked by required conditions
ci / docker_image (push) Has been cancelled
2026-04-13 03:44:38 +00:00
drake feb80e41ea opacity
ci / docker_image (push) Successful in 2m9s
ci / deploy (push) Successful in 24s
2026-04-12 16:28:39 -05:00
drake d9607afd2c spinner sizing
ci / docker_image (push) Successful in 2m12s
ci / deploy (push) Successful in 28s
2026-04-12 15:43:20 -05:00
drake adaf9534b5 visuals
ci / docker_image (push) Successful in 2m9s
ci / deploy (push) Successful in 25s
2026-04-12 01:24:33 -05:00
drake 8ce4ff1210 unprint
ci / docker_image (push) Successful in 2m10s
ci / deploy (push) Successful in 24s
2026-04-11 23:54:15 -05:00
drake 8c26522ec2 test
ci / docker_image (push) Successful in 2m9s
ci / deploy (push) Successful in 25s
2026-04-11 23:49:59 -05:00
drake 264b44f555 fix stale stuff?
ci / docker_image (push) Successful in 2m7s
ci / deploy (push) Successful in 25s
2026-04-11 23:40:23 -05:00
drake bce9a19776 fix account save
ci / docker_image (push) Successful in 2m17s
ci / deploy (push) Successful in 50s
2026-04-11 09:28:45 -05:00
drake e6e4b068c9 Update .gitea/workflows/ci.yaml
ci / docker_image (push) Successful in 2m18s
ci / deploy (push) Successful in 57s
2026-04-04 22:50:19 +00:00
drake 9c221fa828 sonner
ci / docker_image (push) Failing after 2m25s
ci / deploy (push) Has been skipped
2026-04-04 17:37:54 -05:00
drake 3925f4e67a settings and fixes
ci / deploy (push) Blocked by required conditions
ci / docker_image (push) Has been cancelled
2026-04-04 17:37:17 -05:00
drake a0efc6c628 updates 2026-03-30 15:44:11 -05:00
drake 09cdce350d fix inquiry message emails
ci / docker_image (push) Successful in 3m12s
ci / deploy (push) Successful in 27s
2026-02-07 01:27:47 -06:00
drake 81f0113ce2 cleanup inquiries
ci / docker_image (push) Successful in 3m10s
ci / deploy (push) Successful in 26s
2026-02-07 01:11:04 -06:00
drake 1dcbca0445 login cleanup
ci / deploy (push) Blocked by required conditions
ci / docker_image (push) Has been cancelled
2026-02-07 01:08:35 -06:00
drake 2a1436515b Readme fix
ci / docker_image (push) Successful in 3m14s
ci / deploy (push) Successful in 30s
2026-02-07 01:03:54 -06:00
drake 3955f95830 cleanup
ci / docker_image (push) Has been cancelled
ci / deploy (push) Blocked by required conditions
2026-02-07 01:03:12 -06:00
drake 60b5705107 readme 2026-02-07 01:01:37 -06:00
drake cd128d7914 account page 2026-02-07 00:45:35 -06:00
drake ee80f849bd dev
ci / docker_image (push) Successful in 3m42s
ci / deploy (push) Successful in 28s
2026-02-06 22:39:15 -06:00
drake 28968c680c ai path update
ci / docker_image (push) Successful in 3m18s
ci / deploy (push) Successful in 28s
2026-02-06 02:40:24 -06:00
drake 90bbe4bfa4 ai impl
ci / docker_image (push) Failing after 1s
ci / deploy (push) Has been skipped
2026-02-06 02:39:23 -06:00
drake 156e74450a env in container
ci / docker_image (push) Successful in 3m13s
ci / deploy (push) Successful in 29s
2026-02-05 23:48:49 -06:00
drake 6c4ce12b45 inquiry ui
ci / docker_image (push) Successful in 3m9s
ci / deploy (push) Successful in 28s
2026-02-05 23:27:03 -06:00
drake 505434a2d8 inquiries and claims
ci / docker_image (push) Successful in 3m18s
ci / deploy (push) Successful in 28s
2026-02-05 19:09:34 -06:00
drake 331765d21e updates
ci / docker_image (push) Successful in 2m52s
ci / deploy (push) Successful in 28s
2026-02-04 19:22:35 -06:00
drake 5cd3af719d pages
ci / docker_image (push) Successful in 2m57s
ci / deploy (push) Successful in 28s
2026-02-04 18:49:35 -06:00
drake 8ea632a14f items revisions
ci / docker_image (push) Successful in 2m56s
ci / deploy (push) Successful in 26s
2026-02-04 01:30:46 -06:00
drake 4ea6549ac7 Lots of dev
ci / docker_image (push) Successful in 2m55s
ci / deploy (push) Successful in 51s
2026-02-03 00:23:43 -06:00
drake 2c2b08aa1a change internal port to match new build
ci / docker_image (push) Successful in 2m38s
ci / deploy (push) Successful in 24s
2026-01-28 13:22:01 -06:00
drake ff92caf5bd change upload directory
ci / deploy (push) Has been cancelled
ci / docker_image (push) Has been cancelled
2026-01-28 13:20:19 -06:00
drake 989fc8c312 change deployed build
ci / docker_image (push) Successful in 3m5s
ci / deploy (push) Successful in 29s
2026-01-28 13:00:01 -06:00
drake f5c4eb6506 change deps
ci / docker_image (push) Successful in 2m41s
ci / deploy (push) Successful in 39s
2026-01-24 21:09:03 -06:00
drake c445dc13e8 login improvements
ci / docker_image (push) Failing after 2m3s
ci / deploy (push) Has been skipped
2026-01-24 20:55:35 -06:00
drake be7b71fce4 better server build 2026-01-24 20:54:17 -06:00
102 changed files with 5826 additions and 486 deletions
+17
View File
@@ -0,0 +1,17 @@
POSTGRES_HOST=192.168.0.200
POSTGRES_PORT=5432
POSTGRES_DB=fbla
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgrespw
JWT_SECRET=CHANGE_ME
BASE_URL=https://fbla26.marinodev.com
EMAIL_HOST=marinodev.com
EMAIL_PORT=465
EMAIL_USER=westuffind@marinodev.com
EMAIL_PASS=CHANGE_ME
FBLA26_PORT=8000
BODY_SIZE_LIMIT=10MB
LLAMA_PORT=8001
LLAMA_HOST=192.168.0.200
LLAMA_MODEL=Qwen3VL-2B-Instruct-Q4_K_M.gguf
LLAMA_MMPROJ=mmproj-Qwen3VL-2B-Instruct-Q8_0.gguf
-3
View File
@@ -12,9 +12,6 @@ jobs:
- name: Check out repository code
uses: actions/checkout@v4
- name: Install Docker
run: curl -fsSL https://get.docker.com | sh
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
+4
View File
@@ -2,6 +2,10 @@
node_modules
uploads
llm-models
# Output
.output
.vercel
+2 -2
View File
@@ -4,8 +4,8 @@ RUN cd /srv && \
git clone https://git.marinodev.com/drake/fbla26.git
WORKDIR /srv/fbla26
RUN npm ci --omit dev
RUN npm ci
RUN npm run build
EXPOSE 8080
ENTRYPOINT node /srv/fbla26/server.js
ENTRYPOINT node /srv/fbla26/build
+73 -18
View File
@@ -1,38 +1,93 @@
# sv
# Westuffind - FBLA 2026
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Overview
## Creating a project
This is a lost and found application built using [SvelteKit](https://kit.svelte.dev/) for the 2026 FBLA Website Coding &
Development event. It allows users to browse items, post found items, and manage them. The
application is designed for fast performance and a seamless user experience.
If you're seeing this, you've probably already done this step. Congrats!
## Features
- User authentication (login/signup/logout)
- Email-only token-based methods for non-admins
- Browse/search items
- Post found items
- Inquire about items
- Claim items
- Email notifications
- Themes
## Installation
To set up the project locally, follow these steps:
### Prerequisites
- [Node.js](https://nodejs.org/) (LTS recommended)
- [npm](https://www.npmjs.com/) or [pnpm](https://pnpm.io/)
### Clone the repository
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
git clone https://git.marinodev.com/MarinoDev/FBLA25
cd FBLA25
```
## Developing
Create a `.env` file in the root directory and configure environment variables. `.env.example` is provided as a
template.
Download a LLaMA compatible LLM (and mmproj) to `llm-models`. I
recommend [Qwen3-VL-2B-Instruct](https://huggingface.co/Qwen/Qwen3-VL-2B-Instruct-GGUF).
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
### Docker
A `Dockerfile` and `docker-compose.yml` file are provided for running the application in a Docker container.
### Manual
Using Docker is strongly recommended, as it bundles the database and the AI.
#### Install dependencies
```sh
npm install
```
#### Start the development server
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Go to `http://localhost:5173/` (or the port shown in the terminal).
To create a production version of your app:
## Deployment
To deploy the application, build it using:
```sh
npm run build
node build
```
You can preview the production build with `npm run preview`.
## Resources Used
### Technologies
- [SvelteKit](https://kit.svelte.dev/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Shadcn (Svelte version)](https://www.shadcn-svelte.com)
### Libraries
- [dotenv](https://www.npmjs.com/package/dotenv)
- [bcrypt](https://www.npmjs.com/package/bcrypt)
- [desm](https://www.npmjs.com/package/desm)
- [nodemailer](https://www.npmjs.com/package/nodemailer)
- [jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken)
- [postgres.js](https://www.npmjs.com/package/postgres)
- [lucide](https://www.npmjs.com/package/@lucide/svelte)
- [sharp](https://www.npmjs.com/package/sharp)
- [valibot](https://www.npmjs.com/package/valibot)
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+27 -3
View File
@@ -13,14 +13,38 @@ services:
- fbla26
fbla26:
image: git.marinodev.com/marinodev/fbla26_ci:latest
restart: on-failure
restart: unless-stopped
env_file: ".env"
ports:
- "${FBLA26_PORT}:8080"
- "${FBLA26_PORT}:3000"
networks:
- fbla26
volumes:
- ./.env:/srv/fbla26/.env
- ./uploads:/srv/fbla26/uploads/
- /var/fbla26/uploads/:/srv/fbla26/uploads/
llama:
image: ghcr.io/ggml-org/llama.cpp:server
restart: unless-stopped
env_file: ".env"
ports:
- "${LLAMA_PORT}:8080"
networks:
- fbla26
volumes:
- ./llm-models:/models:ro
command:
- -m
- /models/${LLAMA_MODEL}
- --mmproj
- /models/${LLAMA_MMPROJ}
- --host
- 0.0.0.0
- --port
- "8080"
- -n
- "512"
networks:
fbla26:
+2 -1
View File
@@ -24,7 +24,8 @@ export default defineConfig(
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
'no-undef': 'off',
'svelte/no-navigation-without-resolve': 'off'
}
},
{
+1711 -155
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -19,13 +19,16 @@
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.561.0",
"@lucide/svelte": "^1.7.0",
"@sveltejs/adapter-node": "^5.3.2",
"@sveltejs/kit": "2.49.5",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/vite": "^4.1.13",
"@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22",
"@types/nodemailer": "^7.0.9",
"@vitest/browser": "^3.2.4",
"bits-ui": "^2.15.4",
"clsx": "^2.1.1",
@@ -39,6 +42,7 @@
"prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.13",
@@ -51,9 +55,16 @@
},
"dependencies": {
"bcrypt": "^6.0.0",
"desm": "^1.3.1",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mode-watcher": "^1.1.0",
"nodemailer": "^7.0.13",
"postgres": "^3.4.7",
"svelte-preprocess": "^6.0.3"
"sharp": "^0.34.5",
"svelte-preprocess": "^6.0.3",
"valibot": "^1.2.0",
"@rive-app/canvas": "^2.37.1"
}
}
+18
View File
@@ -0,0 +1,18 @@
import express from 'express';
import { handler } from './build/handler.js';
import { join } from 'desm';
const assetsPath = join(import.meta.url, 'uploads');
const app = express();
// Serve static files dynamically from the "static" folder (e.g., for uploads)
app.use('/uploads', express.static(assetsPath));
// Serve the built SvelteKit app
app.use(handler);
// Start the server
app.listen(8080, () => {
console.log('Server running on port 8080');
});
+82 -8
View File
@@ -8,20 +8,22 @@
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card: oklch(0.95 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.606 0.25 292.717);
--primary: oklch(0.5852 0.25 292.717);
--primary-foreground: oklch(0.969 0.016 293.756);
--dull-primary: oklch(0.8225 0.0927 293.01);
--dull-primary-foreground: oklch(0.0 0.016 293.756);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--muted-foreground: oklch(0.502 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--border: oklch(0.9 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.606 0.25 292.717);
--chart-1: oklch(0.646 0.222 41.116);
@@ -37,8 +39,10 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.606 0.25 292.717);
--warning: oklch(0.84 0.16 84);
--warning: oklch(0.6 0.16 84);
--error: oklch(0.577 0.245 27.325);
--positive: oklch(0.5 0.2067 147.18);
--action: oklch(0.4852 0.2263 260.47);
}
.dark {
@@ -48,12 +52,15 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.541 0.281 293.009);
--primary-foreground: oklch(0.969 0.016 293.756);
--primary: oklch(0.5 0.331 293.009);
--primary-foreground: oklch(0.0 0.016 293.756);
--dull-primary: oklch(0.3 0.281 293.009);
--dull-primary-foreground: oklch(0.969 0.016 293.756);
/*--primary-foreground: oklch(0.969 0.016 293.756);*/
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--muted-foreground: oklch(0.655 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
@@ -75,6 +82,8 @@
--sidebar-ring: oklch(0.541 0.281 293.009);
--warning: oklch(0.84 0.16 84);
--error: oklch(0.704 0.191 22.216);
--positive: oklch(0.7522 0.2067 147.18);
--action: oklch(0.7098 0.1872 260.47);
}
@theme inline {
@@ -90,6 +99,8 @@
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-dull-primary: var(--dull-primary);
--color-dull-primary-foreground: var(--dull-primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
@@ -115,6 +126,8 @@
--color-sidebar-ring: var(--sidebar-ring);
--color-warning: var(--warning);
--color-error: var(--error);
--color-positive: var(--positive);
--color-action: var(--action);
}
@layer base {
@@ -127,3 +140,64 @@
}
}
@custom-variant data-open {
&:where([data-state="open"]), &:where([data-open]:not([data-open="false"])) {
@slot;
}
}
@custom-variant data-closed {
&:where([data-state="closed"]), &:where([data-closed]:not([data-closed="false"])) {
@slot;
}
}
@custom-variant data-checked {
&:where([data-state="checked"]), &:where([data-checked]:not([data-checked="false"])) {
@slot;
}
}
@custom-variant data-unchecked {
&:where([data-state="unchecked"]), &:where([data-unchecked]:not([data-unchecked="false"])) {
@slot;
}
}
@custom-variant data-selected {
&:where([data-selected]) {
@slot;
}
}
@custom-variant data-disabled {
&:where([data-disabled="true"]), &:where([data-disabled]:not([data-disabled="false"])) {
@slot;
}
}
@custom-variant data-active {
&:where([data-state="active"]), &:where([data-active]:not([data-active="false"])) {
@slot;
}
}
@custom-variant data-horizontal {
&:where([data-orientation="horizontal"]) {
@slot;
}
}
@custom-variant data-vertical {
&:where([data-orientation="vertical"]) {
@slot;
}
}
@utility no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
+3 -1
View File
@@ -3,7 +3,9 @@
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
user: UserPayload | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
+25
View File
@@ -0,0 +1,25 @@
import jwt from 'jsonwebtoken';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env' });
export const handle = async ({ event, resolve }) => {
const JWT = event.cookies.get('jwt');
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
if (!JWT) {
event.locals.user = null;
} else {
try {
event.locals.user = jwt.verify(JWT, process.env.JWT_SECRET);
} catch {
event.cookies.delete('jwt', { path: '/' });
event.locals.user = null;
}
}
return await resolve(event);
};
+43 -32
View File
@@ -1,21 +1,25 @@
import type { User } from '$lib/types';
import { DefaultUserSettings, type User, type UserPayload } from '$lib/types/user';
import bcrypt from 'bcrypt';
import sql from '$lib/db/db.server';
import type { Cookies } from '@sveltejs/kit';
import { type Cookies, error } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import type { inquiryTokenPayload } from '$lib/types/inquiries';
export function setJWT(cookies: Cookies, user: User) {
export function setJWTCookie(cookies: Cookies, user: User) {
const payload = {
id: user.id,
email: user.email,
name: user.name
name: user.name,
settings: user.settings || DefaultUserSettings,
createdAt: user.createdAt,
lastSignIn: user.lastSignIn
};
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
throw Error('JWT_SECRET not defined');
}
if (process.env.BASE_URL === undefined) {
throw new Error('BASE_URL not defined');
throw Error('BASE_URL not defined');
}
const secure: boolean = process.env.BASE_URL?.includes('https://');
@@ -25,17 +29,25 @@ export function setJWT(cookies: Cookies, user: User) {
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false, secure });
}
// export function checkPerms(cookies: Cookies, perms: number): void {
// const JWT = cookies.get('jwt');
// if (!JWT) throw error(403, 'Unauthorized');
// if (process.env.JWT_SECRET === undefined) {
// throw new Error('JWT_SECRET not defined');
// }
// const user = jwt.verify(JWT, process.env.JWT_SECRET) as User;
// if ((user.perms & perms) !== perms) {
// throw error(403, 'Unauthorized');
// }
// }
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 {
const JWT = cookies.get('jwt');
if (!JWT) throw error(403, 'Unauthorized');
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
return jwt.verify(JWT, process.env.JWT_SECRET) as UserPayload;
}
//
// export function hasPerms(cookies: Cookies, perms: number): boolean {
// const JWT = cookies.get('jwt');
@@ -48,33 +60,32 @@ 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 (typeof user.settings == 'string') {
user.settings = JSON.parse(user.settings);
}
if (user) {
if (await bcrypt.compare(password, user.passwordHash!)) {
delete user.passwordHash;
if (await bcrypt.compare(password, user.password_hash)) {
delete user.password_hash;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
sql`
await 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 ');
}
throw Error('Invalid email or password');
}
// await createUser(<User>{
// name: 'Drake',
// username: 'drake',
// email: 'drake@marinodev.com',
// password: 'password',
// perms: 255,
// name: 'Drake'
// password: 'password'
// });
@@ -0,0 +1,79 @@
<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';
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 onOpenAutoFocus={(e) => e.preventDefault()}>
<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="?/claim&id={item?.id}">
<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}
<span class="mt-2"><strong class="mr-1">Found at:</strong> {item.foundLocation}</span>
{/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>
@@ -0,0 +1,183 @@
<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 TrashIcon from '@lucide/svelte/icons/trash';
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';
import { Textarea } from '$lib/components/ui/textarea';
import * as Card from '$lib/components/ui/card';
import { dateFormatOptions } from '$lib/shared';
import { Badge } from '$lib/components/ui/badge';
import { deleteInquiry } from '$lib/db/inquiries.remote';
import { invalidateAll } from '$app/navigation';
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(file: File) {
isGenerating = true;
const buffer = await file.arrayBuffer();
const base64 = btoa(
String.fromCharCode(...new Uint8Array(buffer))
);
description = await genDescription(base64);
isGenerating = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class={item?.threads ? '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">
<div class="flex justify-between w-full">
<Dialog.Close
type="button"
class={buttonVariants({ variant: "destructive" })}
onclick={async () => {await approveDenyItem({ id: item?.id || 0, approved: false }); await invalidateAll(); }}
>
Delete
</Dialog.Close>
<div>
<Dialog.Close
type="button"
class={buttonVariants({ variant: "outline" })}
>
Cancel
</Dialog.Close>
<Button type="submit" class="ml-1">Submit</Button>
</div>
</div>
</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)}
<Card.Root class="mt-4">
<Card.Header>
<Card.Title>{thread.createdAt.toLocaleDateString('en-US', dateFormatOptions)}
<!--{#if thread.messages[thread.messages.length - 1].sender === 'inquirer'}-->
<!-- t-->
<!--{/if}-->
</Card.Title>
<Card.Description
>{thread.messages[0].body}
</Card.Description
>
<Card.Action class="items-center flex gap-2">
<a href="/items/{item.id}/inquiries/{thread.id}"
class="{buttonVariants({variant: 'ghost'})} text-action">Reply?</a>
<Button variant="ghost" class="text-destructive"
onclick={() => {deleteInquiry(thread.id); invalidateAll();}}>
<TrashIcon />
</Button>
</Card.Action>
</Card.Header>
</Card.Root>
{/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}
@@ -0,0 +1,180 @@
<script lang="ts">
import ImagePlus from '@lucide/svelte/icons/image-plus';
import X from '@lucide/svelte/icons/x';
import { onDestroy } from 'svelte';
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);
// derived value instead of initializing from required
let canRemove = $derived(!required);
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);
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"
onchange={onInputChange}
formaction="/item?/describe"
hidden
/>
<button
class="dropzone"
class:has-image={!!previewUrl}
class:dragging={dragging}
onclick={openFileDialog}
ondragover={(e) => {e.preventDefault(); dragging = !disabled;}}
ondragleave={() => (dragging = false)}
ondrop={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 && canRemove}
<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>
{/if}
</div>
<style>
.dropzone {
position: relative;
width: 100%;
max-height: 200px;
min-height: 80px;
border-radius: 12px;
border: 2px dashed var(--border);
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,93 @@
<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';
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 onOpenAutoFocus={(e) => e.preventDefault()}>
<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&id=' + item?.id}>
<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}
<span class="mt-2 flex"><strong class="mr-1">Found at:</strong> {item.foundLocation}</span>
{/if}
</div>
</div>
<Field.Field>
<Field.Label for="inquiry">
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>
@@ -0,0 +1,153 @@
<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';
import CheckIcon from '@lucide/svelte/icons/check';
import XIcon from '@lucide/svelte/icons/x';
import PencilIcon from '@lucide/svelte/icons/pencil';
import NotebookPenIcon from '@lucide/svelte/icons/notebook-pen';
import TrashIcon from '@lucide/svelte/icons/trash';
import StarIcon from '@lucide/svelte/icons/star';
import ArchiveRestoreIcon from '@lucide/svelte/icons/archive-restore';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { dateFormatOptions } from '$lib/shared';
import { approveDenyItem, restoreClaimedItem } from '$lib/db/items.remote';
import { invalidateAll } from '$app/navigation';
import NoImagePlaceholder from './no-image-placeholder.svelte';
import type { User } from '$lib/types/user';
export let item: Item = <Item>{};
export let user: User | null = null;
export let editCallback: (item: Item) => void;
export let inquireCallback: (item: Item) => void;
export let claimCallback: (item: Item) => void;
// Normalize the foundDate in case it was serialized (string) in prod builds
// (SSR/JSON serialization often turns Date objects into strings).
const foundDateObj: Date = item?.foundDate instanceof Date
? item.foundDate
: new Date(item?.foundDate);
// Compute days since posted as a number (always a number, fall back to 0 on invalid date)
let timeSincePosted: number = (() => {
const t = foundDateObj?.getTime?.();
if (!t || Number.isNaN(t)) return 0;
return (Date.now() - t) / 1000 / 60 / 60 / 24; // days (float)
})();
// If you want to round/display differently you can do that in the template.
</script>
<div
class="text-left h-full bg-card text-card-foreground flex flex-col gap-2 rounded-xl border shadow-sm max-w-sm overflow-hidden min-w-2xs" onclick={() => {claimCallback(item)}}>
{#if item.image}
<img src="https://fbla26.marinodev.com/uploads/{item.id}.jpg" class="object-cover min-h-56 max-h-56"
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="flex-col flex h-full px-2 pb-2">
<div>
{#if item.transferred}
<Badge variant="secondary" class="inline-block">In Lost & Found</Badge>
{:else}
<Badge variant="secondary" class="inline-block">With Finder</Badge>
{/if}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger
>
<Badge variant="outline"
class="inline-block {user?.settings?.staleItemDays !== undefined && timeSincePosted >= user.settings.staleItemDays ? 'text-warning' : ''}">{timeSincePosted < 1 ? "<1" : Math.round(timeSincePosted)}
day{(timeSincePosted <= 1) ? '' : 's'} ago
</Badge>
</Tooltip.Trigger
>
<Tooltip.Content>
<p>{foundDateObj.toLocaleDateString('en-US', dateFormatOptions)}</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<div class="flex-1">{item.description}</div>
{#if item.foundLocation}
<div>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<span class="mt-2 flex"><strong class="mr-1">Found at:</strong> {item.foundLocation}</span>
</Tooltip.Trigger
>
<Tooltip.Content>
<p>Item was found here</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
{/if}
{#if user !== null}
<div class="mt-2 justify-between flex">
{#if item.approvedDate === null}
<Button variant="ghost" class="text-positive"
onclick={async (e) => {e.stopPropagation(); await approveDenyItem({id: item.id, approved: true});
await invalidateAll()}}>
<CheckIcon />
Approve
</Button>
<Button variant="ghost" class="text-destructive"
onclick={async (e) => {e.stopPropagation(); await approveDenyItem({id: item.id, approved: false});
await invalidateAll()}}>
<XIcon />
Deny
</Button>
{/if}
{#if item.claimedDate === null}
<Button variant="ghost" class="text-action"
onclick={(e) => {e.stopPropagation(); editCallback(item)}}>
<PencilIcon />
{!item.approvedDate ? 'Edit' : 'Manage'}
</Button>
{:else}
<Button variant="ghost" class="text-destructive"
onclick={async (e) => {e.stopPropagation(); await approveDenyItem({id: item.id, approved: false});
await invalidateAll()}}>
<TrashIcon />
Delete
</Button>
<Button variant="ghost" class="text-action"
onclick={async (e) => {e.stopPropagation(); await restoreClaimedItem(item.id);
await invalidateAll()}}>
<ArchiveRestoreIcon />
Restore
</Button>
{/if}
</div>
{:else}
<div class="mt-2 justify-between flex">
<Button variant="ghost" class="text-action z-10"
onclick={(e) => {e.stopPropagation(); inquireCallback(item)}}>
<NotebookPenIcon />
Inquire
</Button>
<Button variant="ghost" class="text-primary z-10"
onclick={(e) => {e.stopPropagation(); claimCallback(item)}}>
<StarIcon />
Claim
</Button>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,42 @@
<script>
import { onMount, onDestroy } from 'svelte';
export let src = '/mdev_triangle_loading.riv';
export let stateMachine = null;
export let size = 120;
export let classes = '';
let canvas;
let riveInstance;
onMount(async () => {
if (typeof window === 'undefined') return;
const mod = await import('@rive-app/canvas');
// Handle BOTH CJS and ESM shapes safely
const Rive = mod?.Rive || mod?.default?.Rive || mod?.default;
if (!Rive) {
console.error('Rive failed to load:', mod);
return;
}
riveInstance = new Rive({
src,
canvas,
autoplay: true,
// stateMachines: stateMachine || undefined,
onLoad: () => {
riveInstance.resizeDrawingSurfaceToCanvas();
}
});
});
onDestroy(() => {
riveInstance?.cleanup?.();
});
</script>
<canvas bind:this={canvas} class={classes} />
@@ -0,0 +1,26 @@
<script>
// Define props for size and color, with default values
export let size = 24;
export let color = 'currentColor';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
viewBox="0 0 24 19"
fill={color}
stroke-width="0"
stroke-linecap="round"
stroke-linejoin="round"
{...$$restProps}
>
<path
d="m12.005 0.1012c-0.33679 0-0.56131 0.38889-0.56131 0.38889l-1.2349 2.1389 4.8113 8.3333c1.1806-0.26901 1.8534 1.2973 0.84835 1.9695-1.0051 0.6722-2.1957-0.54773-1.4963-1.5362l-4.6124-7.9889-8.4197 14.583c-0.14969 0.25926-0.16839 0.48611-0.056127 0.68056 0.11226 0.19445 0.31808 0.29167 0.61745 0.29167h2.4698l4.8113-8.3333c-0.82328-0.88794 0.19678-2.2537 1.2815-1.7194 1.0847 0.53431 0.62352 2.1754-0.58222 2.0639l-4.6124 7.9889h16.839s0.44905 0 0.61744-0.29167c0.16838-0.29167-0.05613-0.68056-0.05613-0.68056l-1.2349-2.1389h-9.6225c-0.35734 1.157-2.0502 0.95647-2.1298-0.25005-0.07962-1.2065 1.5722-1.6277 2.0785-0.52773h9.2248l-8.4197-14.583s-0.22452-0.38889-0.56132-0.38889" />
</svg>
<style>
/* Optional: Add CSS for styling, if needed */
svg {
display: block;
}
</style>
@@ -0,0 +1,122 @@
<script>
export let className = '';
</script>
<svg
version="1.1"
id="svg2"
viewBox="0 0 99.999999 99.999999"
height="100"
width="100"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"
class={className}>
<defs
id="defs4">
<linearGradient
id="linearGradient967">
<stop
id="stop963"
offset="0"
style="stop-color:#c2c2c2;stop-opacity:1" />
<stop
id="stop965"
offset="1"
style="stop-color:#9f9f9f;stop-opacity:1" />
</linearGradient>
<linearGradient
gradientTransform="translate(-45.254833,0.35355338)"
gradientUnits="userSpaceOnUse"
y2="108.77648"
x2="658.45801"
y1="6.5995569"
x1="660.06653"
id="linearGradient969"
xlink:href="#linearGradient967" />
</defs>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
transform="translate(-399.13437,-122.79051)"
id="layer1">
<g
transform="matrix(0.60784825,0,0,0.67101051,134.74354,126.08684)"
id="g1015">
<rect
style="fill:#9f9f9f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.2995;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1002"
width="82.489967"
height="90.470001"
x="398.75827"
y="178.74706"
ry="8.3970251"
transform="rotate(-16.342822)" />
<g
id="g1000"
transform="rotate(16.320529,538.13563,-184.89727)">
<rect
ry="4.5961938"
y="1.6498091"
x="547.18585"
height="115.96551"
width="107.83378"
id="rect961"
style="fill:url(#linearGradient969);fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:5.398;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none" />
<g
style="stroke:#ffffff;stroke-width:13.0708;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0.17265471,0,0,0.17265471,512.49324,-6.3296456)"
id="g875">
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#cccccc;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:13.0708;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect3338"
width="491.10556"
height="449.99814"
x="270"
y="107.36227" />
<rect
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:13.0708;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="rect3342"
width="491.10559"
height="209.99976"
x="270"
y="107.36227" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#cccccc;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:13.0708;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;paint-order:stroke;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 300,317.36255 38.46147,-53.53818 60.53097,-45.16084 15.88277,18.57394 13.61285,-38.68356 8.20133,-2.98188 13.3106,-28.2093 180,179.99979"
id="path3344" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#b3b3b3;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:13.0708;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;paint-order:stroke;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 180,60 c 4.09311,16.474688 7.71219,33.067277 10.85156,49.75 2.38256,12.66097 4.48857,25.37408 6.31641,38.12695 l -22.06445,-7.16015 -46.11133,-29.41602 5.32422,46.42578 -1.61524,24.78711 10.05274,30.37695 73.18554,-11.75585 L 300,180 252.19922,102.56641 242.5,117.5 215.375,95.375 Z"
transform="translate(270,107.36227)"
id="path3390-0" />
<path
id="path3358"
d="m 419.99999,347.36252 81.89918,-74.42959 18.50574,-9.68009 23.6512,-44.18894 25.94388,-21.70121 179.99999,179.99979"
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#cccccc;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:13.0708;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;paint-order:stroke;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#b3b3b3;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:13.0708;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;paint-order:stroke;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 569.99999,197.36269 35.9388,80.91289 v 30.11038 30.11038 l 22.45864,19.46652 c 6.52453,-6.45031 14.14893,-11.78526 22.44431,-15.70477 14.8245,-7.00447 31.33823,-9.35959 47.17057,-13.6217 6.42776,-1.73037 12.90672,-3.85419 18.21343,-7.87277 1.35174,-1.02362 2.61592,-2.16281 3.77424,-3.40107 h -30 l -40.52149,-40.55006 -29.85645,-48.91972 -10.25307,8.83886 z"
id="path3386" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:13.0708;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;paint-order:stroke;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 419.99999,557.36227 c -0.41699,-9.60089 -8.81759,-17.60878 17.1252,-30.66806 31.8318,-16.02389 125.895,-35.88836 152.1537,-59.98434 19.42709,-17.82687 -70.4154,-37.66945 -55.0191,-59.07323 6.981,-9.70528 59.037,-19.96947 82.1463,-30.27386 21.90569,-9.76799 15.14129,-19.80328 31.4046,-29.97507 15.7092,-9.82558 68.3499,-19.77358 72.18929,-30.02516 -10.41359,10.52188 -68.83379,20.40327 -89.99999,30.00026 -22.3377,10.128 -21.4689,19.93018 -49.4313,29.48367 -30.1245,10.29239 -89.142,20.55268 -102.7077,30.51626 -28.4133,20.86858 46.863,42.59995 16.2024,59.99993 C 452.54309,490.92554 344.7219,510.65712 300,527.3626 c -30.9039,11.54369 -28.4079,17.74799 -30,29.99967"
id="path3370" />
</g>
</g>
</g>
</g>
</svg>
@@ -0,0 +1,164 @@
<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 { fileToBase64 } from '$lib/shared';
import LoadingSpinner from './loading-spinner.svelte';
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 { Textarea } from '$lib/components/ui/textarea';
let itemLocation: string | undefined = $state('');
let foundLocation: string | undefined = $state();
let description: string | undefined = $state();
let isGenerating = $state(false);
async function resizeImage(
file: File,
maxWidth = 300,
maxHeight = 300,
quality = 0.8
): Promise<File> {
const bitmap = await createImageBitmap(file);
let { width, height } = bitmap;
const scale = Math.min(maxWidth / width, maxHeight / height, 1);
width = Math.round(width * scale);
height = Math.round(height * scale);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(bitmap, 0, 0, width, height);
const blob = await new Promise<Blob>((resolve) =>
canvas.toBlob((blob) => resolve(blob!), "image/jpeg", quality)
);
return new File([blob], file.name, {
type: "image/jpeg",
lastModified: Date.now()
});
}
async function onSelect(file: File) {
isGenerating = true;
const resized = await resizeImage(file, 300, 300, 0.7);
const base64 = await fileToBase64(resized);
description = await genDescription(base64);
isGenerating = false;
}
let { open = $bindable() }: { open: boolean } = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Content onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>Submit Found Item</Dialog.Title>
<Dialog.Description>
Your item will need to be approved before becoming public.
</Dialog.Description>
</Dialog.Header>
<form method="post" action="/items?/create" enctype="multipart/form-data">
<Field.Group>
<ImageUpload onSelect={onSelect} required />
<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 did you find it?
</Field.Label>
<Input
id="foundLocation"
name="foundLocation"
bind:value={foundLocation}
placeholder="By the tennis courts."
/>
</Field.Field>
<RadioGroup.Root name="location" bind:value={itemLocation} required>
<Field.Label for="location">
Where is it? <span class="text-error">*</span>
</Field.Label>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="finderPossession" id="finderPossession" />
<Label for="finderPossession">
I still have the item.
</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="turnedIn" id="turnedIn" />
<Label for="turnedIn">
I turned the item in to the school lost and found.
</Label>
</div>
</RadioGroup.Root>
<Field.Field
class={itemLocation !== "finderPossession" ? "hidden pointer-events-none opacity-50" : ""}
>
<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={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>
</Dialog.Content>
</Dialog.Root>
<div
class="fixed inset-0 bg-black/85 z-[999999] w-screen h-screen flex justify-center items-center transition-opacity duration-150 flex-col md:flex-row"
class:opacity-0={!isGenerating}
class:pointer-events-none={!isGenerating}
>
<LoadingSpinner classes="w-28 md:w-48 mb-2 md:-mb-4" />
<p class="text-4xl md:text-6xl text-white">Loading...</p>
</div>
-64
View File
@@ -1,64 +0,0 @@
<script lang="ts">
import {
FieldGroup,
Field,
FieldLabel,
FieldDescription,
FieldSeparator,
} 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 {
ref = $bindable(null),
class: className,
...restProps
}: WithElementRef<HTMLFormAttributes> = $props();
const id = $props.id();
</script>
<form class={cn("flex flex-col gap-6", className)} bind:this={ref} {...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-{id}">Email</FieldLabel>
<Input id="email-{id}" type="email" placeholder="m@example.com" required />
</Field>
<Field>
<div class="flex items-center">
<FieldLabel for="password-{id}">Password</FieldLabel>
<a href="##" class="ms-auto text-sm underline-offset-4 hover:underline">
Forgot your password?
</a>
</div>
<Input id="password-{id}" type="password" required />
</Field>
<Field>
<Button type="submit">Login</Button>
</Field>
<FieldSeparator>Or continue with</FieldSeparator>
<Field>
<Button variant="outline" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
fill="currentColor"
/>
</svg>
Login with GitHub
</Button>
<FieldDescription class="text-center">
Don't have an account?
<a href="##" class="underline underline-offset-4">Sign up</a>
</FieldDescription>
</Field>
</FieldGroup>
</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";
+27 -25
View File
@@ -1,38 +1,40 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = 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 shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
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 shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
default: 'bg-dull-primary text-dull-primary-foreground hover:bg-primary/90 shadow-xs',
// dull_default: 'bg-dull-primary text-dull-primary-foreground hover:bg-dull-primary/90 shadow-xs',
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs',
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
'bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
xl: 'h-14 rounded-md px-8 has-[>svg]:px-6',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: "default",
size: "default",
},
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
@@ -44,11 +46,11 @@
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
variant = 'default',
size = 'default',
ref = $bindable(null),
href = undefined,
type = "button",
type = 'button',
disabled,
children,
...restProps
@@ -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} />
+1
View File
@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";
@@ -0,0 +1,34 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import CircleCheckIcon from '@lucide/svelte/icons/circle-check';
import OctagonXIcon from '@lucide/svelte/icons/octagon-x';
import InfoIcon from '@lucide/svelte/icons/info';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
>
{#snippet loadingIcon()}
<Loader2Icon class="size-4 animate-spin" />
{/snippet}
{#snippet successIcon()}
<CircleCheckIcon class="size-4" />
{/snippet}
{#snippet errorIcon()}
<OctagonXIcon class="size-4" />
{/snippet}
{#snippet infoIcon()}
<InfoIcon class="size-4" />
{/snippet}
{#snippet warningIcon()}
<TriangleAlertIcon class="size-4" />
{/snippet}
</Sonner>
+7
View File
@@ -0,0 +1,7 @@
import Root from "./switch.svelte";
export {
Root,
//
Root as Switch,
};
@@ -0,0 +1,29 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
class={cn(
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
+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
@@ -0,0 +1,19 @@
import Root from "./tooltip.svelte";
import Trigger from "./tooltip-trigger.svelte";
import Content from "./tooltip-content.svelte";
import Provider from "./tooltip-provider.svelte";
import Portal from "./tooltip-portal.svelte";
export {
Root,
Trigger,
Content,
Provider,
Portal,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
Provider as TooltipProvider,
Portal as TooltipPortal,
};
@@ -0,0 +1,52 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import TooltipPortal from "./tooltip-portal.svelte";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
side = "top",
children,
arrowClasses,
portalProps,
...restProps
}: TooltipPrimitive.ContentProps & {
arrowClasses?: string;
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof TooltipPortal>>;
} = $props();
</script>
<TooltipPortal {...portalProps}>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
{side}
class={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--bits-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...restProps}
>
{@render children?.()}
<TooltipPrimitive.Arrow>
{#snippet child({ props })}
<div
class={cn(
"bg-foreground z-50 size-2.5 rotate-45 rounded-[2px]",
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]",
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]",
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2",
"data-[side=left]:-translate-y-[calc(50%_-_3px)]",
arrowClasses
)}
{...props}
></div>
{/snippet}
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Content>
</TooltipPortal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ...restProps }: TooltipPrimitive.PortalProps = $props();
</script>
<TooltipPrimitive.Portal {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ...restProps }: TooltipPrimitive.ProviderProps = $props();
</script>
<TooltipPrimitive.Provider {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
</script>
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: TooltipPrimitive.RootProps = $props();
</script>
<TooltipPrimitive.Root bind:open {...restProps} />
+20
View File
@@ -7,3 +7,23 @@ 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 =
"^(?!\\.)(?!.*\\.\\.)([a-zA-Z0-9_'+\\-\\.]*)[a-zA-Z0-9_'+\\-]@([a-zA-Z0-9][a-zA-Z0-9\\-]*\\.)+[a-zA-Z]{2,}$";
+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;
+11
View File
@@ -0,0 +1,11 @@
import { getRequestEvent, query } from '$app/server';
import * as v from 'valibot';
import sql from '$lib/db/db.server';
import { verifyJWT } from '$lib/auth/index.server';
export const deleteInquiry = query(v.number(), async (id) => {
const { cookies } = getRequestEvent();
verifyJWT(cookies);
await sql`DELETE FROM inquiry_threads WHERE id = ${id};`;
});
+70
View File
@@ -0,0 +1,70 @@
import { command, getRequestEvent } from '$app/server';
import * as v from 'valibot';
import sql from '$lib/db/db.server';
import { verifyJWT } from '$lib/auth/index.server';
import sharp from 'sharp';
import { LLMDescribe } from '$lib/llm/llm.server';
export const genDescription = command(v.string(), async (data) => {
const inputBuffer = Buffer.from(data, 'base64');
// Convert File → Buffer
// const inputBuffer = Buffer.from(await file.arrayBuffer());
// Detect format (Sharp does this internally)
const image = sharp(inputBuffer).rotate();
const outputBuffer = await image
.rotate()
.resize({ fit: 'outside', height: 300, width: 300 })
.jpeg({ quality: 70 }) // adjust if needed
.toBuffer();
const description = await LLMDescribe(
`data:image/jpeg;base64,${outputBuffer.toString('base64')}`
);
return description;
});
export const approveDenyItem = command(
v.object({ id: v.number(), approved: v.boolean() }),
async ({ id, approved }) => {
const { cookies } = getRequestEvent();
const userPayload = verifyJWT(cookies);
if (approved) {
const reponse = await sql`
UPDATE items
SET
approved_date = NOW(),
emails = (
SELECT array_agg(DISTINCT email)
FROM (
SELECT ${userPayload.email} AS email
UNION ALL
SELECT u.email
FROM users u
WHERE (u.settings->>'notifyAllApprovedInquiries')::boolean = true
) t
)
WHERE id = ${id};
`;
} else {
await sql`
DELETE FROM items WHERE id = ${id};
`;
}
}
);
export const restoreClaimedItem = command(v.number(), async (id) => {
const { cookies } = getRequestEvent();
verifyJWT(cookies);
const reponse = await sql`
UPDATE items
SET claimed_date = null
WHERE id = ${id};
`;
});
View File
+8 -60
View File
@@ -1,67 +1,15 @@
import type { User } from '$lib/types';
import { DefaultUserSettings, type User } from '$lib/types/user';
import sql from '$lib/db/db.server';
import bcrypt from 'bcrypt';
// should require MANAGE_USERS permission
export async function createUser(user: User): Promise<User> {
const password_hash: string = await bcrypt.hash(user.password!, 12);
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 + '%'}`});
const response = await sql`
INSERT INTO users (username, email, name, password_hash, settings, created_at, last_sign_in)
VALUES (${user.username}, ${user.email}, ${user.name}, ${password_hash}, ${sql.json(DefaultUserSettings)}, NOW(), NOW()) RETURNING id;
`;
}
user.id = response[0].id;
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}
`;
return user;
}
+145
View File
@@ -0,0 +1,145 @@
import nodemailer from 'nodemailer';
import sql from '$lib/db/db.server';
import jwt from 'jsonwebtoken';
import { type inquiryTokenPayload, Sender } from '$lib/types/inquiries';
import type { Item } from '$lib/types/item';
// Create a transporter object using SMTP transport
export 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 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 = ${inquiryId} ) 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!);
// Send mail with defined transport object
await transporter.sendMail({
from: `Westuffind Notifier <${process.env.EMAIL_USER}>`,
to: item.emails,
// to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
replyTo: `${process.env.EMAIL_USER!.split('@')[0]}+${replyToken}${process.env.EMAIL_USER!.split('@')[1]}`,
subject: 'New Item Inquiry!',
text: `Someone has made an inquiry on the item with description: ${item.description}\nThey ask: ${item.threads![0].messages[0].body}\n\n\nRespond to this email directly, or click the below link to reply on Westuffinder\n${process.env.BASE_URL}/items/${item.id}/inquiries/${inquiryId}?token=${replyToken}`
});
}
export async function sendInquiryMessageEmail(inquiryId: number, sender: Sender) {
const [item]: Item[] = await sql`
SELECT
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 = ${inquiryId} ) 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
};
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}`
});
}
export async function sendClaimEmail(id: number, email: string) {
const [item]: Item[] = await sql`
SELECT * FROM items WHERE id = ${id};`;
if (!item.transferred) {
// Send mail with defined transport object
await transporter.sendMail({
from: `Westuffind Notifier <${process.env.EMAIL_USER}>`,
to: item.emails,
replyTo: email,
// to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
subject: 'Your Item was Claimed!',
text: `Someone has claimed your item with description: ${item.description}\nReply to this email explaining how they can pick up the item from you. Replies to this email go directly to the claimer.`
});
}
}
+42
View File
@@ -0,0 +1,42 @@
export async function LLMDescribe(imageData: string) {
const payload = {
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Describe only the main object in this image in one sentence. Include its color, shape, brand, or any text on it. Focus solely on the object itself. Example: A blue Hydroflask water bottle with a dented lid and a sticker of a mountain on the side.'
},
{
type: 'image_url',
image_url: {
url: imageData
}
}
]
}
],
max_tokens: 128,
temperature: 0.2
};
const res = await fetch(
`http://${process.env.LLAMA_HOST!}:${process.env.LLAMA_PORT!}/v1/chat/completions`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
}
);
if (!res.ok) {
console.error(await res.text());
// process.exit(1);
}
const data = await res.json();
return data.choices[0].message.content;
}
+44 -12
View File
@@ -1,4 +1,4 @@
import type { User } from '$lib/types';
import type { User } from '$lib/types/user';
export const getCookieValue = (name: string): string =>
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
@@ -14,24 +14,40 @@ export const getUserFromJWT = (jwt: string): User => JSON.parse(atob(jwt.split('
// return userData;
// };
export function getFormString(data: FormData, key: string): string | undefined {
const value = data.get(key);
if (value === null) {
return undefined;
}
// export function getFormString(data: FormData, key: string): string | undefined {
// const value = data.get(key);
// if (value === null) {
// return undefined;
// }
//
// if (typeof value !== 'string') {
// throw fail(400, {
// error: `Incorrect input in field ${key}.`,
// success: false
// });
// }
// return value.trim();
// }
if (typeof value !== 'string') {
throw Error(`Incorrect input in field ${key}.`);
export function getFormString(data: FormData, key: string) {
const value = data.get(key);
if (!value) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed !== '') return trimmed;
}
return value.trim();
throw Error(`Invalid field ${key}.`);
}
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') {
const trimmed = value.trim();
if (trimmed !== '') return trimmed;
}
return value.trim();
throw Error(`Missing/invalid required field ${key}.`);
}
export const dateFormatOptions: Intl.DateTimeFormatOptions = {
@@ -39,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);
});
}
-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[];
}
+24
View File
@@ -0,0 +1,24 @@
export enum Sender {
ADMIN = 'admin',
FINDER = 'finder',
INQUIRER = 'inquirer'
}
export interface Message {
id: number;
sender: Sender;
body: string;
createdAt: Date;
}
export interface Thread {
id: number;
messages: Message[];
createdAt: Date;
}
export interface inquiryTokenPayload {
sender: Sender;
threadId: number;
messageId?: number;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Thread } from '$lib/types/inquiries';
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;
deleted: boolean;
image: boolean;
threads?: Thread[];
}
+30
View File
@@ -0,0 +1,30 @@
export interface User {
id: number;
username: string;
email: string;
name: string;
password?: string;
passwordHash?: string;
settings?: UserSettings;
createdAt: Date;
lastSignIn: Date;
}
export interface UserPayload {
id: number;
email: string;
username: string;
name: string;
}
export type UserSettings = {
staleItemDays: number;
notifyAllApprovedInquiries?: boolean;
notifyAllTurnedInInquiries?: boolean;
};
export const DefaultUserSettings: UserSettings = {
staleItemDays: 30,
notifyAllApprovedInquiries: false,
notifyAllTurnedInInquiries: false
};
+34
View File
@@ -0,0 +1,34 @@
<script>
import { page } from '$app/state';
</script>
<div class="text-center" style="padding-top: 32px">
<h1 class="text-9xl font-bold">
{page.status}
</h1>
<h1 class="text-5xl mb-6">That's an error</h1>
{#if page.status === 404}
<p>We cant seem to find the page you are looking for.</p>
<p>The address may be mistyped, or the page may have moved or been deleted.</p>
{/if}
{#if page.status === 403}
<p>You dont have access to this page!</p>
<p>Please contact your admin if you think this is a mistake</p>
{/if}
{#if page.status === 401}
<p>You must be signed-in to view this page!</p>
{/if}
{#if page.status === 500}
<p>This one is on our end...</p>
<p>We are working to resolve this as fast as possible.</p>
{/if}
{#if page.status === 400}
<p>Invalid request!</p>
<p>Make sure you have correctly inputted all information.</p>
{/if}
{#if page.status !== 404 && page.status !== 403 && page.status !== 401 && page.status !== 500 && page.status !== 400}
<p>An unexpected error has occurred.</p>
<p>Please try again later</p>
{/if}
<p class="mt-8">Error: {page.error?.message}</p>
</div>
+2 -7
View File
@@ -1,10 +1,5 @@
import type { LayoutServerLoad } from './$types';
import { getUserFromJWT } from '$lib/shared';
export const load: LayoutServerLoad = ({ cookies }) => {
const jwt = cookies.get('jwt');
if (jwt) {
return { selfUser: getUserFromJWT(jwt) };
}
return { selfUser: null };
export const load: LayoutServerLoad = async ({ locals }) => {
return { user: locals.user };
};
+57 -20
View File
@@ -1,29 +1,66 @@
<script lang="ts">
import '../app.css';
import MdevTriangle from '$lib/components/custom/mdev-triangle.svelte';
import { ModeWatcher, toggleMode } from 'mode-watcher';
import SunIcon from '@lucide/svelte/icons/sun';
import MoonIcon from '@lucide/svelte/icons/moon';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
let { children } = $props();
let { children, data } = $props();
</script>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..40,400,0,0&display=block&icon_names=account_circle,arrow_drop_down,arrow_drop_up,calendar_today,call,check,close,cloud_upload,dark_mode,delete,description,edit,group,home,info,light_mode,login,mail,menu,open_in_new,person,search,sell,store,upload,visibility,visibility_off,work"
rel="stylesheet"
/>
<ModeWatcher />
<!--<header class="bottom-border flex justify-between">-->
<!-- <a class="material-symbols-outlined p-2 !text-3xl leading-none" href="/">home</a>-->
<!-- {#if data.selfUser}-->
<!-- <a class="material-symbols-outlined p-2 !text-3xl leading-none" href="/account"-->
<!-- >account_circle</a-->
<!-- >-->
<!-- {:else}-->
<!-- <a class="material-symbols-outlined p-2 !text-3xl leading-none" href="/signin">login</a>-->
<!-- {/if}-->
<!--</header>-->
<div class="flex flex-col min-h-dvh">
<div style="max-width: 800px;" class="center">
<div class="flex justify-center">
<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">Westuffinder</span>
</a>
<div class="items-center flex gap-4">
{#if data.user}
<div class="inline-block">
<a href="/account" class={buttonVariants({variant: 'outline'})}>
Account
</a>
</div>
{/if}
<div class="inline-block">
<Button onclick={toggleMode} variant="outline" size="icon">
<SunIcon
class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90"
/>
<MoonIcon
class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0"
/>
<span class="sr-only">Toggle theme</span>
</Button>
</div>
</div>
</header>
</div>
<div class="flex-1">
<div class="flex justify-center">
<main class="w-full">
{@render children?.()}
</main>
</div>
</div>
<footer class="gap-2 py-2 text-center text-sm text-muted-foreground">
<p>
{new Date().getFullYear()} MarinoDev. <a href="https://git.marinodev.com/drake/fbla26"
class="hover:underline">Git Repository</a>
</p>
<!-- <p>-->
<!-- <a href="/privacy" class="underline hover:text-gray-700">Privacy Policy</a> |-->
<!-- <a href="/terms" class="underline hover:text-gray-700">Terms of Service</a>-->
<!-- </p>-->
</footer>
</div>
<footer>
</footer>
+232 -2
View File
@@ -1,6 +1,236 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script lang="ts">
import { Button, buttonVariants } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import SubmitItemDialog from '$lib/components/custom/submit-item-dialog.svelte';
import { cn } from '$lib/utils';
let createDialogOpen: boolean = $state(false);
function openCreateDialog() {
createDialogOpen = true;
}
</script>
<section class="relative overflow-hidden" aria-labelledby="hero-heading">
<div class="mx-auto max-w-6xl px-6 py-24 text-center">
<h1 id="hero-heading" class="text-4xl font-bold tracking-tight sm:text-5xl">
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?
This is the official place to reconnect items with their owners.
</p>
<div class="mt-10 flex flex-col items-center justify-center gap-4 ">
<a
href="/items"
class={cn(buttonVariants({ variant: 'default', size: 'xl' }), 'text-base')}
aria-label="Browse lost items"
>
Browse lost items
</a>
<div class="w-40 flex items-center gap-2 h-2">
<div class="flex-1 h-px bg-border"></div>
<span class="text-muted-foreground whitespace-nowrap">Or</span>
<div class="flex-1 h-px bg-border"></div>
</div>
<Button
size="sm"
variant="outline"
class="text-muted-foreground"
onclick={openCreateDialog}
aria-label="Submit a found item"
>
Submit found item
</Button>
</div>
</div>
<div
aria-hidden="true"
class="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl"
>
<div
class="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-primary to-primary/40 opacity-20 sm:left-[calc(50%-30rem)] sm:w-[72rem]"
></div>
</div>
</section>
<!-- FEATURES -->
<section class="mx-auto max-w-6xl px-6 py-12" aria-labelledby="features-heading">
<h2 id="features-heading" class="text-2xl font-semibold text-center mb-12">
Everything you need to get items back fast
</h2>
<div class="grid gap-12 md:grid-cols-2 items-center">
<!-- Left visual / emphasis block -->
<div class="space-y-6">
<div class="rounded-2xl border bg-muted/30 p-6">
<h3 class="font-semibold text-lg">Built for speed</h3>
<p class="text-sm text-muted-foreground mt-2">
Report and browse items in seconds. The entire flow is designed to reduce friction.
</p>
</div>
<div class="rounded-2xl border bg-muted/20 p-6">
<h3 class="font-semibold text-lg">Organized listings</h3>
<p class="text-sm text-muted-foreground mt-2">
Items are structured by location and category so nothing gets buried.
</p>
</div>
<div class="rounded-2xl border bg-muted/10 p-6">
<h3 class="font-semibold text-lg">Trusted process</h3>
<p class="text-sm text-muted-foreground mt-2">
Every submission is reviewed before it's visible.
</p>
</div>
</div>
<!-- Right visual anchor -->
<div class="rounded-2xl border p-10 bg-gradient-to-br from-primary/10 to-transparent">
<h3 class="text-xl font-semibold">Designed for school life</h3>
<p class="text-sm text-muted-foreground mt-4">
Backpacks, phones, notebooks, jackets — the things that actually get lost daily.
Everything here is tuned for real student use, not generic “lost & found” systems.
</p>
<div class="mt-6 text-xs text-muted-foreground">
Over <span class="font-medium text-foreground">120+</span> items successfully returned
</div>
</div>
</div>
</section>
<!-- HOW IT WORKS (Vertical Timeline Style) -->
<section class="mx-auto max-w-4xl px-6 py-12" aria-labelledby="how-heading">
<h2 id="how-heading" class="text-2xl font-semibold text-center mb-12">
How it works
</h2>
<div class="relative border-l pl-8 space-y-10">
<div class="relative">
<div class="absolute -left-[40px] top-1 h-4 w-4 rounded-full bg-primary"></div>
<h3 class="font-semibold">Someone finds something</h3>
<p class="text-sm text-muted-foreground mt-1">
An item is discovered in a classroom, hallway, locker room, or common area.
</p>
</div>
<div class="relative">
<div class="absolute -left-[40px] top-1 h-4 w-4 rounded-full bg-primary/70"></div>
<h3 class="font-semibold">It gets submitted</h3>
<p class="text-sm text-muted-foreground mt-1">
A quick submission captures what it is and where it was found.
</p>
</div>
<div class="relative">
<div class="absolute -left-[40px] top-1 h-4 w-4 rounded-full bg-primary/40"></div>
<h3 class="font-semibold">It makes its way home</h3>
<p class="text-sm text-muted-foreground mt-1">
The owner identifies it and retrieves it through staff.
</p>
</div>
</div>
</section>
<!-- ACTION / INFO (Staggered Bento Style Instead of 3 Equal Cards) -->
<section class="mx-auto max-w-6xl px-6 py-24" aria-labelledby="info-heading">
<h2 id="info-heading" class="sr-only">Actions</h2>
<div class="grid md:grid-cols-2 gap-6">
<!-- Large primary card -->
<Card class="">
<CardHeader>
<CardTitle>Found something?</CardTitle>
</CardHeader>
<CardContent class="space-y-4 text-sm text-muted-foreground">
<p>
Help return it in under a minute. Every submission makes a difference.
</p>
<Button variant="outline" class="w-full" onclick={openCreateDialog}>
Submit Item
</Button>
</CardContent>
</Card>
<!-- Side card -->
<Card class="">
<CardHeader>
<CardTitle>Looking for yours?</CardTitle>
</CardHeader>
<CardContent class="space-y-4 text-sm text-muted-foreground">
<p>
Check the live list of items turned in around school.
</p>
<a
href="/items"
class={cn(buttonVariants({ variant: 'outline' }), 'w-full')}
>
Browse
</a>
</CardContent>
</Card>
</div>
<!-- Wide bottom emphasis card -->
<Card class="md:col-span-3 border-muted bg-muted/20 mt-6">
<CardContent class=" flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h3 class="font-semibold">Built on trust</h3>
<p class="text-sm text-muted-foreground">
A simple system powered by students and staff working together.
</p>
</div>
<div class="text-sm text-muted-foreground">
<span class="font-medium text-foreground">120+</span> items returned and counting
</div>
</CardContent>
</Card>
</section>
<!-- IMPORTANT DISCLAIMER -->
<section class="mx-auto max-w-6xl px-6 pb-20" aria-labelledby="policy-heading">
<Card class="border-amber-500/40 bg-amber-500/5">
<CardHeader>
<CardTitle id="policy-heading">Important Notice</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground space-y-3">
<p>
This platform is intended for everyday personal items such as notebooks, clothing,
water bottles, and similar belongings commonly misplaced at school.
</p>
<p>
Items of significant monetary, legal, or sensitive value (including but not limited to cash,
credit cards, IDs, or high-value electronics) should <strong>not</strong> be handled through
this system.
</p>
<p>
Such items must be immediately reported to the main office or appropriate school staff for
proper handling and documentation.
</p>
</CardContent>
</Card>
</section>
<section class="mx-auto max-w-6xl px-6 pb-24" aria-labelledby="staff-heading">
<h2 id="staff-heading" class="sr-only">Staff Access</h2>
<div class="flex items-center justify-center">
<p class="text-xs text-muted-foreground">
Staff only:
<a
href="/login"
class="ml-1 underline underline-offset-4 hover:text-foreground"
aria-label="Admin sign in"
>
Admin sign in
</a>
</p>
</div>
</section>
<SubmitItemDialog bind:open={createDialogOpen} />
+76
View File
@@ -0,0 +1,76 @@
import type { PageServerLoad } from '$types';
import sql from '$lib/db/db.server';
import type { User, UserSettings } from '$lib/types/user';
import { type Actions, error, fail } from '@sveltejs/kit';
import { getFormString, getRequiredFormString } from '$lib/shared';
import bcrypt from 'bcrypt';
import { setJWTCookie } from '$lib/auth/index.server';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals || !locals.user) {
throw Error('Need to be logged in!');
}
const [userData] = await sql`
SELECT * FROM users WHERE id = ${locals.user.id}
`;
userData.settings = JSON.parse(userData.settings);
return { userData };
};
export const actions: Actions = {
default: async ({ request, url, locals, params, cookies }) => {
if (!locals || !locals.user) {
throw error(403, 'Need to be logged in!');
}
const data = await request.formData();
let name: string;
let email: string;
let newPassword: string | null;
let retypeNewPassword: string | null;
let staleItemDays: number;
let notifyAllApprovedInquiries: boolean;
let notifyAllTurnedInInquiries: boolean;
try {
name = getRequiredFormString(data, 'name');
email = getRequiredFormString(data, 'email');
newPassword = getFormString(data, 'newPassword');
retypeNewPassword = getFormString(data, 'retypeNewPassword');
staleItemDays = parseInt(getRequiredFormString(data, 'staleItemDays'));
notifyAllApprovedInquiries = getFormString(data, 'notifyAllApprovedInquiries') == 'on';
notifyAllTurnedInInquiries = getFormString(data, 'notifyAllTurnedInInquiries') == 'on';
if (newPassword !== retypeNewPassword) {
fail(400, { password: 'New passwords dont match!' });
}
const passwordHash = newPassword ? await bcrypt.hash(newPassword, 12) : null;
const settings: UserSettings = {
staleItemDays,
notifyAllApprovedInquiries,
notifyAllTurnedInInquiries
};
const [res] = await sql`
UPDATE users SET name = ${name},
email = ${email},
${passwordHash ? sql`password_hash = ${passwordHash},` : sql``}
settings = ${JSON.stringify(settings)}
WHERE id = ${locals.user.id}
RETURNING *;
`;
res.settings = JSON.parse(res.settings);
setJWTCookie(cookies, res as User);
} catch (e) {
return fail(400, {
message: e instanceof Error ? e.message : 'Unknown error occurred',
success: false
});
}
return { success: true };
}
} satisfies Actions;
+180
View File
@@ -0,0 +1,180 @@
<!-- src/routes/account/++page.svelte -->
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Separator } from '$lib/components/ui/separator';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { Button } from '$lib/components/ui/button';
import { DefaultUserSettings } from '$lib/types/user';
import { enhance } from '$app/forms';
import { toast } from 'svelte-sonner';
import { Toaster } from '$lib/components/ui/sonner';
import CheckIcon from '@lucide/svelte/icons/check';
let { data, form } = $props();
function signOut() {
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
window.location.href = '/';
}
// Use top-level variables for two-way binding. Binding to object properties
// like `form.name` doesn't create proper reactive two-way bindings in Svelte.
let name: string = $state(data.userData.name);
let email: string = $state(data.userData.email);
let staleItemDays: number =
$state(data.userData.settings?.staleItemDays ?? DefaultUserSettings.staleItemDays);
let notifyAllApprovedInquiries: boolean =
$state(data.userData.settings?.notifyAllApprovedInquiries ?? false);
let notifyAllTurnedInInquiries: boolean =
$state(data.userData.settings?.notifyAllTurnedInInquiries ?? false);
const formatDate = (date: Date | string) => {
const d = new Date(date);
return d.toLocaleString();
};
</script>
<svelte:head>
<title>Account</title>
</svelte:head>
<Toaster></Toaster>
<div class="container mx-auto max-w-3xl py-10">
<Card class="rounded-2xl shadow-sm">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-2xl font-semibold">Account Overview</CardTitle>
<Badge variant="secondary">ID #{data.userData.id}</Badge>
</div>
</CardHeader>
<CardContent class="space-y-6">
<!-- Editable Profile Form -->
<form method="POST" class="space-y-6" use:enhance={({ formElement, formData, action, cancel, submitter }) => {
return async ({ result, update }) => {
if (result.status === 200) {
toast('Saved', {
icon: CheckIcon,
description: 'Your account has been saved!',
});
}
};
}
}>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="username">Username</Label>
<Input id="username" value={data.userData.username} disabled />
</div>
<div class="space-y-2">
<Label for="name">Full Name<span class="text-error">*</span></Label>
<Input id="name" name="name" bind:value={name} />
</div>
<div class="space-y-2 sm:col-span-2">
<Label for="email">Email<span class="text-error">*</span></Label>
<Input id="email" name="email" type="email" bind:value={email} />
</div>
<div class="space-y-2">
<Label for="newPassword">New Password</Label>
<Input id="newPassword" name="newPassword" />
</div>
<div class="space-y-2">
<Label for="retypeNewPassword">Retype New Password</Label>
<Input id="retypeNewPassword" name="retypeNewPassword" />
</div>
</div>
<Separator />
<div class="space-y-4">
<h2 class="text-lg font-semibold">Settings</h2>
<div class="flex items-center justify-between">
<Label for="staleItemDays" class="text-base">
<div>
<p class="font-medium">Stale Item Days</p>
<p class="text-sm text-muted-foreground">
Number of days without activity before items show up as stale.
</p>
</div>
</Label>
<Input
class="w-min inline-block"
id="staleItemDays"
name="staleItemDays"
type="number"
bind:value={staleItemDays}
min="0"
/>
</div>
<div class="flex items-center justify-between">
<Label for="notifyAllApprovedInquiries" class="text-base">
<div>
<p class="font-medium">Notify for All Approved Items</p>
<p class="text-sm text-muted-foreground">
Receive notifications for all approved items, not just yours.
</p>
</div>
</Label>
<Switch
name="notifyAllApprovedInquiries"
id="notifyAllApprovedInquiries"
bind:checked={notifyAllApprovedInquiries}
/>
</div>
<div class="flex items-center justify-between">
<Label for="notifyAllTurnedInInquiries" class="text-base">
<div>
<p class="font-medium">Notify for All Turned In Items</p>
<p class="text-sm text-muted-foreground">
Receive notifications for all items turned in to the school lost-and-found.
</p>
</div>
</Label>
<Switch
name="notifyAllTurnedInInquiries"
id="notifyAllTurnedInInquiries"
bind:checked={notifyAllTurnedInInquiries}
/>
</div>
</div>
<div class="flex items-center justify-between pt-4">
<div class="text-sm text-muted-foreground">
<p>
Member since {formatDate(data.userData.createdAt)}
</p>
<p>
Last sign in {formatDate(data.userData.lastSignIn)}
</p>
</div>
<div class="flex gap-2">
<Button type="submit">Save Changes</Button>
<Button type="button" onclick={signOut} variant="destructive">
Sign Out
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
</div>
+407
View File
@@ -0,0 +1,407 @@
import type { Actions, PageServerLoad } from '$types';
import { fail } from '@sveltejs/kit';
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';
import { getFormString, getRequiredFormString } from '$lib/shared';
import { sendClaimEmail, sendNewInquiryEmail } from '$lib/email/sender.server';
export const load: PageServerLoad = async ({ url, locals }) => {
const searchQuery = url.searchParams.get('search');
// If the user is logged in, fetch items together with their threads (each thread contains its first message)
if (locals && locals.user) {
try {
const items: 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 IS NOT NULL) 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
${searchQuery ? sql`WHERE word_similarity(${searchQuery}, i.description) > 0.3` : sql``}
GROUP BY i.id
ORDER BY i.found_date DESC;
`;
items.forEach((item) =>
item.threads?.forEach((thread) => (thread.createdAt = new Date(thread.createdAt)))
);
return { items };
// 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))), 'createdAt' , t.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[];
// console.log(rawThreads);
// if (rawThreads.length === 0) {
// item.threads = null;
// } else {
// 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[];
if (!searchQuery) {
items = await sql`SELECT * FROM items ORDER BY found_date DESC;`;
} else {
items = await sql`
SELECT * FROM items
WHERE word_similarity(${searchQuery}, description) > 0.3
ORDER BY word_similarity(${searchQuery}, description);
`;
}
return { items };
};
export const actions: Actions = {
create: async ({ request }) => {
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');
const file = data.get('image');
// if (file === null)
// return fail(400, {
// message: `Missing required field image.`,
// success: false
// });
if (!email && location !== 'turnedIn') {
fail(400, {
message: "Email is required if it is still in finder's possession",
success: false
});
}
let outputBuffer: Buffer | undefined;
if (file) {
if (!(file instanceof File)) {
return fail(400, { message: 'No file uploaded or file is invalid', success: false });
}
// Convert File → Buffer
const inputBuffer = Buffer.from(await file.arrayBuffer());
// Detect format (Sharp does this internally)
const image = sharp(inputBuffer).rotate();
// image = image.rotate();
const metadata = await image.metadata();
if (metadata.format === 'jpeg') {
// Already JPG → keep as-is
outputBuffer = inputBuffer;
} else {
// Convert to JPG
outputBuffer = await image
.jpeg({ quality: 90 }) // adjust if needed
.toBuffer();
}
}
const response = await sql`
INSERT INTO items (
emails,
description,
transferred,
found_location,
image
) VALUES (
${
location === 'turnedIn'
? sql`(
SELECT array_agg(DISTINCT email)
FROM (
SELECT ${email} AS email
UNION ALL
SELECT u.email
FROM users u
WHERE (u.settings->>'notifyAllTurnedInInquiries')::boolean = true
) t
)`
: [email]
},
${description},
${location === 'turnedIn'},
${foundLocation},
${file instanceof File}
)
RETURNING id;
`;
if (outputBuffer) {
try {
// It's a good idea to validate the filename to prevent path traversal attacks
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 };
},
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) {
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).rotate();
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 = await sql`
WITH new_thread AS (
INSERT INTO inquiry_threads (item_id, inquirer_email)
VALUES (${id}, ${email})
RETURNING id
)
INSERT INTO inquiry_messages (thread_id, sender, body)
SELECT
id,
'inquirer',
${inquiry}
FROM new_thread
RETURNING
thread_id,
id AS message_id;
`;
await sendNewInquiryEmail(response[0].threadId);
} 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 email: string;
try {
email = getRequiredFormString(data, 'email');
const response = await sql`
UPDATE items SET claimed_date = NOW()
WHERE id = ${id};
`;
await sendClaimEmail(id, email);
} catch (e) {
return fail(400, {
message: e instanceof Error ? e.message : 'Unknown error occurred',
success: false
});
}
}
// describe: async ({ request }) => {
// const data = await request.formData();
//
// let image = data.get('image');
// if (!(image instanceof File) || image.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());
//
// // Detect format (Sharp does this internally)
// const image = sharp(inputBuffer).rotate();
//
// const outputBuffer = await image
// .rotate()
// .resize({ fit: 'outside', height: 400, width: 400 })
// .jpeg({ quality: 90 }) // adjust if needed
// .toBuffer();
//
// const description = LLMDescribe(`data:image/jpeg;base64,${outputBuffer.toString('base64')}`);
// console.log(description);
//
// return description;
// }
} satisfies Actions;
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { Button, buttonVariants } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte';
import type { PageProps } from '$types';
import { EMAIL_REGEX_STRING } from '$lib/consts';
import { genDescription } from '$lib/db/items.remote';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Label } from '$lib/components/ui/label';
import ItemListing from '$lib/components/custom/item-listing.svelte';
import { FieldSeparator } from '$lib/components/ui/field';
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';
// import { type Item } from '$lib/types/item';
let createDialogOpen: boolean = $state(false);
function openCreateDialog() {
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();
</script>
<div class="max-w-7xl mx-auto px-4">
<div class="justify-between flex mb-4">
<form action="" class="w-full">
<Field.Field orientation="horizontal" class="w-full max-w-md">
<Input type="search" name="search" id="search" placeholder="Search..." />
<Button variant="default" type="submit">Search</Button>
</Field.Field>
</form>
<!-- <h1 class="text-3xl mb-2 mt-2">Found Items</h1>-->
<div class="hidden md:inline-block">
<Button variant="default" class="" onclick={openCreateDialog}>
Submit a found item
</Button>
</div>
</div>
{#if data.items.length === 0}
<h1 class="text-xl text-center font-semibold mt-16">No items found!</h1>
<p class="text-center">Try broadening your search terms to get more results.</p>
{:else}
<div class="grid gap-4 grid-cols-[repeat(auto-fill,minmax(18rem,max-content))] justify-center">
{#if data.user && data.items.some(
(item) => item.approvedDate === null
)}
<FieldSeparator class="col-span-full text-lg h-8 my-2">
Pending items
</FieldSeparator>
{#each data.items as item (item.id)}
{#if item.approvedDate === null}
<ItemListing item={item} user={data.user} editCallback={openEditDialog}
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
{/if}
{/each}
{/if}
{#if data.user && data.items.some(
(item) => item.approvedDate !== null && item.claimedDate === null
)}
<FieldSeparator class="col-span-full text-lg my-2 h-8 w-full">
Public items
</FieldSeparator>
{/if}
{#each data.items as item (item.id)}
{#if item.approvedDate !== null && item.claimedDate === null}
<ItemListing item={item} user={data.user} editCallback={openEditDialog}
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
{/if}
{/each}
{#if data.user && data.items.some(
(item) => item.claimedDate !== null
)}
<FieldSeparator class="col-span-full text-lg my-2 h-8 w-full">
Claimed items
</FieldSeparator>
{#each data.items as item (item.id)}
{#if item.claimedDate !== null}
<ItemListing item={item} user={data.user} editCallback={openEditDialog}
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
{/if}
{/each}
{/if}
</div>
{/if}
</div>
<Button class="fixed shadow-lg bottom-6 right-6 rounded-xl md:hidden p-6 text-xl z-20" size="default"
onclick={openCreateDialog}>
<PlusIcon size={32} />
New item
</Button>
<SubmitItemDialog bind:open={createDialogOpen} />
<EditItemDialog bind:open={editDialogOpen} item={editItem} />
<InquireItemDialog bind:open={inquireDialogOpen} item={inquireItem} />
<ClaimItemDialog bind:open={claimDialogOpen} item={claimItem} />
@@ -0,0 +1,101 @@
import type { Actions, PageServerLoad } from '$types';
import { type inquiryTokenPayload, 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';
import { sendInquiryMessageEmail } from '$lib/email/sender.server';
import type { Item } from '$lib/types/item';
export const load: PageServerLoad = async ({ url, locals, params }) => {
const token: string | undefined = url.searchParams.get('token');
const inquiryId: string = params.inquiryId;
if (token) {
try {
jwt.verify(token, process.env.JWT_SECRET!);
} catch {
throw error(403, 'Your response token does not match this inquiry!');
}
} else if (!locals || !locals.user) {
throw error(
403,
'You must be either signed in, or have a respond token to access this inquiry!'
);
}
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 = ${inquiryId} ) 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;`;
return { item };
};
export const actions: Actions = {
reply: async ({ request, url, locals, params }) => {
const token: string | undefined = url.searchParams.get('token');
const inquiryId: number = params.inquiryId;
let sender: Sender | undefined;
if (locals && locals.user) {
sender = Sender.ADMIN;
} else if (token) {
let decoded: inquiryTokenPayload;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET!) as inquiryTokenPayload;
sender = decoded.sender;
} catch {
throw error(403, 'Your response token does not match this inquiry!');
}
if (decoded.threadId.toString() !== inquiryId.toString()) {
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');
const response = await sql`
INSERT INTO inquiry_messages (thread_id, sender, body)
VALUES (${inquiryId}, ${sender}, ${body})
RETURNING id;
`;
let nextReplySender: Sender;
if (sender === Sender.ADMIN || sender === Sender.FINDER) {
nextReplySender = Sender.INQUIRER;
} else {
nextReplySender = Sender.FINDER;
}
await sendInquiryMessageEmail(inquiryId, nextReplySender);
return { success: true };
}
} satisfies Actions;
@@ -0,0 +1,107 @@
<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';
import LocationIcon from '@lucide/svelte/icons/map-pinned';
import { page } from '$app/state';
import * as item from 'valibot';
import NoImagePlaceholder from '$lib/components/custom/no-image-placeholder.svelte';
import { Separator } from '$lib/components/ui/separator';
import { dateFormatOptions } from '$lib/shared';
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';
}
};
const dateTimeFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
};
</script>
<div class="max-w-2xl mx-auto p-4">
<div class="flex gap-4">
{#if data.item.image}
<img src="https://fbla26.marinodev.com/uploads/{data.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="">
<div class="flex-1">{data.item.description}</div>
{#if data.item.foundLocation}
<span class="mt-2"><strong class="mr-1">Found at:</strong> {item.foundLocation}</span>
{/if}
</div>
</div>
<Separator class="my-6" />
<div class="relative">
<div class="flex flex-col gap-6 relative">
{#if data.item.threads}
{#each data.item.threads[0].messages as msg (msg.id)}
<div class="flex items-start gap-4">
<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>
</div>
<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">{new Date(msg.createdAt as unknown as string).toLocaleDateString('en-US', dateTimeFormatOptions)}</span>
</div>
</div>
{/each}
{/if}
</div>
</div>
<!-- Input area -->
<form class="mt-6 flex flex-col gap-2" method="post" action="?/reply&token={page.url.searchParams.get('token')}">
<Input
type="text"
placeholder="Write a message..."
class="resize-none"
name="message"
/>
<Button class="self-end" type="submit">Send
<SendIcon />
</Button>
</form>
</div>
+20
View File
@@ -0,0 +1,20 @@
import { type Actions, fail, redirect } from '@sveltejs/kit';
import { getRequiredFormString } from '$lib/shared';
import { login, setJWTCookie } from '$lib/auth/index.server';
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
try {
const email = getRequiredFormString(data, 'email');
const password = getRequiredFormString(data, 'password');
const user = await login(email, password);
setJWTCookie(cookies, user);
} catch (e) {
return fail(400, { message: e instanceof Error ? e.message : 'Unknown error occurred' });
}
throw redirect(303, '/items');
}
} satisfies Actions;
+60 -17
View File
@@ -1,30 +1,73 @@
<script lang="ts">
import GalleryVerticalEndIcon from '@lucide/svelte/icons/gallery-vertical-end';
import LoginForm from '$lib/components/login-form.svelte';
import type { PageProps } from '$types';
import {
FieldGroup,
Field,
FieldLabel
} from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
let { form }: PageProps = $props();
</script>
<div class="grid min-h-svh lg:grid-cols-2">
<div class="flex flex-col gap-4 p-6 md:p-10">
<div class="flex justify-center gap-2 md:justify-start">
<a href="##" class="flex items-center gap-2 font-medium">
<div
class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
>
<GalleryVerticalEndIcon class="size-4" />
</div>
Acme Inc.
</a>
</div>
<div class="flex flex-1 items-center justify-center">
<div class="flex flex-col gap-4 md:p-6">
<div class="flex-1"></div>
<div class="justify-center flex">
<div class="w-full max-w-xs">
<LoginForm />
<form class="flex flex-col gap-6" method="post" aria-labelledby="login-heading">
<FieldGroup>
<div class="flex flex-col items-center gap-1 text-center">
<h1 id="login-heading" class="text-2xl font-bold">Login to your account</h1>
<p id="login-description" class="text-muted-foreground text-sm text-balance">
Only admins need to log in.<br>Lost something? Go <a href="/" class="underline text-primary">home</a>.
</p>
</div>
<Field>
<FieldLabel for="email">Email</FieldLabel>
<Input
id="email"
type="email"
name="email"
placeholder="m@example.com"
required
aria-required="true"
/>
</Field>
<Field>
<div class="flex items-center">
<FieldLabel for="password">Password</FieldLabel>
</div>
<Input
id="password"
name="password"
type="password"
required
aria-required="true"
aria-describedby={form?.message ? "password-error" : undefined}
aria-invalid={form?.message ? "true" : "false"}
/>
{#if form?.message}
<p id="password-error" class="text-error" role="alert" aria-live="assertive">
{form.message}
</p>
{/if}
</Field>
<Field>
<Button type="submit" aria-label="Login to your account">Login</Button>
</Field>
</FieldGroup>
</form>
</div>
</div>
<div class="flex-3"></div>
</div>
<div class="bg-muted relative hidden lg:block">
<img
src="/placeholder.svg"
alt="placeholder"
src="login-hero.png"
alt=""
aria-hidden="true"
class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
/>
</div>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import {
FieldGroup,
Field,
FieldLabel
} from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLFormAttributes } from 'svelte/elements';
import type { PageProps } from '$types';
let {
class: className,
form,
...restProps
}: WithElementRef<HTMLFormAttributes> & { form?: PageProps } = $props();
</script>
<form class={cn("flex flex-col gap-6", className)} {...restProps} method="post">
<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">
Only admins need to log in.<br>Lost something? Go <a href="/" class="underline text-primary">home</a>.
</p>
</div>
<Field>
<FieldLabel for="email">Email</FieldLabel>
<Input id="email" name="email" type="email" placeholder="m@example.com" required />
</Field>
<Field>
<div class="flex items-center">
<FieldLabel for="password">Password</FieldLabel>
</div>
<Input id="password" type="password" required />
</Field>
<Field>
<Button type="submit">Login</Button>
</Field>
</FieldGroup>
</form>
+2 -2
View File
@@ -1,9 +1,9 @@
import { page } from '@vitest/browser/context';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import Page from './+page.svelte';
import Page from './items/+page.svelte';
describe('/+page.svelte', () => {
describe('/++page.svelte', () => {
it('should render h1', async () => {
render(Page);
+7 -7
View File
@@ -1,16 +1,16 @@
<script>
import GalleryVerticalEndIcon from "@lucide/svelte/icons/gallery-vertical-end";
import SignupForm from "$lib/components/signup-form.svelte";
<script lang="ts">
import SignupForm from './signup-form.svelte';
import MdevTriangle from '$lib/components/custom/mdev-triangle.svelte';
</script>
<div class="grid min-h-svh lg:grid-cols-2">
<div class="grid min-h-svh lg:grid-cols-2 w-screen">
<div class="flex flex-col gap-4 p-6 md:p-10">
<div class="flex justify-center gap-2 md:justify-start">
<a href="#/" class="flex items-center gap-2 font-medium">
<a href="/" class="flex items-center gap-2 font-medium">
<div
class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
class="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md"
>
<GalleryVerticalEndIcon class="size-4" />
<MdevTriangle />
</div>
Acme Inc.
</a>
@@ -1,9 +1,9 @@
<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";
import { cn } from '$lib/utils.js';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import type { HTMLAttributes } from 'svelte/elements';
let { class: className, ...restProps }: HTMLAttributes<HTMLFormElement> = $props();
</script>
@@ -40,19 +40,9 @@
<Field.Field>
<Button type="submit">Create Account</Button>
</Field.Field>
<Field.Separator>Or continue with</Field.Separator>
<Field.Field>
<Button variant="outline" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
fill="currentColor"
/>
</svg>
Sign up with GitHub
</Button>
<Field.Description class="px-6 text-center">
Already have an account? <a href="#/">Sign in</a>
Already have an account? <a href="/login">Sign in</a>
</Field.Description>
</Field.Field>
</Field.Group>
Binary file not shown.

After

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Some files were not shown because too many files have changed in this diff Show More