Compare commits
45
Commits
5349d60a3c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60ad76d48a | ||
|
|
38fe82731e | ||
|
|
5cce7928ec | ||
|
|
6f375cd990 | ||
|
|
681b15e793 | ||
|
|
53a75f6573 | ||
|
|
eb40444e46 | ||
|
|
1b179f9d16 | ||
|
|
7e6e31aa83 | ||
|
|
ee19bd9dea | ||
|
|
aa501b4c16 | ||
|
|
feb80e41ea | ||
|
|
d9607afd2c | ||
|
|
adaf9534b5 | ||
|
|
8ce4ff1210 | ||
|
|
8c26522ec2 | ||
|
|
264b44f555 | ||
|
|
bce9a19776 | ||
|
|
e6e4b068c9 | ||
|
|
9c221fa828 | ||
|
|
3925f4e67a | ||
|
|
a0efc6c628 | ||
|
|
09cdce350d | ||
|
|
81f0113ce2 | ||
|
|
1dcbca0445 | ||
|
|
2a1436515b | ||
|
|
3955f95830 | ||
|
|
60b5705107 | ||
|
|
cd128d7914 | ||
|
|
ee80f849bd | ||
|
|
28968c680c | ||
|
|
90bbe4bfa4 | ||
|
|
156e74450a | ||
|
|
6c4ce12b45 | ||
|
|
505434a2d8 | ||
|
|
331765d21e | ||
|
|
5cd3af719d | ||
|
|
8ea632a14f | ||
|
|
4ea6549ac7 | ||
|
|
2c2b08aa1a | ||
|
|
ff92caf5bd | ||
|
|
989fc8c312 | ||
|
|
f5c4eb6506 | ||
|
|
c445dc13e8 | ||
|
|
be7b71fce4 |
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
node_modules
|
||||
|
||||
uploads
|
||||
|
||||
llm-models
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+1711
-155
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+3
-1
@@ -3,7 +3,9 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
user: UserPayload | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
@@ -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,}$";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};`;
|
||||
});
|
||||
@@ -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};
|
||||
`;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user