initial commit
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Replace with your DB credentials!
|
||||||
|
DATABASE_URL="postgres://user:password@host:port/db-name"
|
||||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
10
.prettierignore
Normal file
10
.prettierignore
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/static/
|
||||||
|
/drizzle/
|
||||||
16
.prettierrc
Normal file
16
.prettierrc
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tailwindStylesheet": "src/routes/layout.css"
|
||||||
|
}
|
||||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"files.associations": {
|
||||||
|
"*.css": "tailwind"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
README.md
Normal file
38
README.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project in the current directory
|
||||||
|
npx sv create
|
||||||
|
|
||||||
|
# create a new project in my-app
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
11
drizzle.config.ts
Normal file
11
drizzle.config.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/lib/server/db/schema.ts',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: { url: process.env.DATABASE_URL },
|
||||||
|
verbose: true,
|
||||||
|
strict: true
|
||||||
|
});
|
||||||
41
eslint.config.js
Normal file
41
eslint.config.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import { defineConfig } from 'eslint/config';
|
||||||
|
import globals from 'globals';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
|
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
includeIgnoreFile(gitignorePath),
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
...svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser, ...globals.node }
|
||||||
|
},
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: ['.svelte'],
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
4871
package-lock.json
generated
Normal file
4871
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "ft-inv",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"test:unit": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/compat": "^1.4.0",
|
||||||
|
"@eslint/js": "^9.38.0",
|
||||||
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
|
"@sveltejs/kit": "^2.47.1",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"drizzle-kit": "^0.31.5",
|
||||||
|
"drizzle-orm": "^0.44.6",
|
||||||
|
"eslint": "^9.38.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-svelte": "^3.12.4",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
|
"svelte": "^5.41.0",
|
||||||
|
"svelte-check": "^4.3.3",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.1",
|
||||||
|
"vite": "^7.1.10"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@lucia-auth/adapter-drizzle": "^1.1.0",
|
||||||
|
"arctic": "^3.7.0",
|
||||||
|
"lucia": "^3.2.2",
|
||||||
|
"postgres": "^3.4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app.css
Normal file
37
src/app.css
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-black text-white antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-zinc-800 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-zinc-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-spin {
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-spin::-webkit-outer-spin-button,
|
||||||
|
.no-spin::-webkit-inner-spin-button {
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
16
src/app.d.ts
vendored
Normal file
16
src/app.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
interface Locals {
|
||||||
|
user: import('lucia').User | null;
|
||||||
|
session: import('lucia').Session | null;
|
||||||
|
}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { };
|
||||||
11
src/app.html
Normal file
11
src/app.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
36
src/hooks.server.ts
Normal file
36
src/hooks.server.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { lucia } from '$lib/server/auth';
|
||||||
|
import { redirect, type Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
const sessionId = event.cookies.get(lucia.sessionCookieName);
|
||||||
|
if (!sessionId) {
|
||||||
|
event.locals.user = null;
|
||||||
|
event.locals.session = null;
|
||||||
|
} else {
|
||||||
|
const { session, user } = await lucia.validateSession(sessionId);
|
||||||
|
if (session && session.fresh) {
|
||||||
|
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||||
|
// sveltekit types deviates from the de-facto standard
|
||||||
|
// you can use 'as any' too
|
||||||
|
event.cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||||
|
path: '.',
|
||||||
|
...sessionCookie.attributes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!session) {
|
||||||
|
const sessionCookie = lucia.createBlankSessionCookie();
|
||||||
|
event.cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||||
|
path: '.',
|
||||||
|
...sessionCookie.attributes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
event.locals.user = user;
|
||||||
|
event.locals.session = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.locals.user && !event.url.pathname.startsWith('/login')) {
|
||||||
|
redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
109
src/lib/components/DeleteConfirmModal.svelte
Normal file
109
src/lib/components/DeleteConfirmModal.svelte
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
export let show = false;
|
||||||
|
export let itemIds: string[] = [];
|
||||||
|
// Optional: pass item names for display if deleting a single item, or just "X items"
|
||||||
|
export let itemName: string | null = null;
|
||||||
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
export let type: 'item' | 'folder' = 'item';
|
||||||
|
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
|
||||||
|
$: if (show && dialog && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
} else if (!show && dialog && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
class="m-auto w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-950 p-0 text-white shadow-2xl backdrop:bg-black/80"
|
||||||
|
on:close={close}
|
||||||
|
>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="mb-4 flex items-center gap-3 text-red-500">
|
||||||
|
<div class="rounded-full bg-red-900/20 p-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-bold text-white">
|
||||||
|
Delete {itemIds.length > 1
|
||||||
|
? type === 'folder'
|
||||||
|
? 'Folders'
|
||||||
|
: 'Items'
|
||||||
|
: type === 'folder'
|
||||||
|
? 'Folder'
|
||||||
|
: 'Item'}?
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mb-6 text-zinc-400">
|
||||||
|
{#if itemIds.length === 1 && itemName}
|
||||||
|
Are you sure you want to delete <span class="font-semibold text-white">"{itemName}"</span>?
|
||||||
|
{:else}
|
||||||
|
Are you sure you want to delete <span class="font-semibold text-white"
|
||||||
|
>{itemIds.length}
|
||||||
|
{type === 'folder'
|
||||||
|
? itemIds.length > 1
|
||||||
|
? 'folders'
|
||||||
|
: 'folder'
|
||||||
|
: itemIds.length > 1
|
||||||
|
? 'items'
|
||||||
|
: 'item'}</span
|
||||||
|
>?
|
||||||
|
{/if}
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={type === 'folder' ? '?/deleteFolder' : '?/bulkDelete'}
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
close();
|
||||||
|
// Force reload to update list
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex justify-end gap-3"
|
||||||
|
>
|
||||||
|
{#if type === 'folder'}
|
||||||
|
<input type="hidden" name="id" value={itemIds[0]} />
|
||||||
|
{:else}
|
||||||
|
<input type="hidden" name="ids" value={JSON.stringify(itemIds)} />
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={close}
|
||||||
|
class="rounded-md px-4 py-2 font-medium text-zinc-400 transition-colors hover:bg-zinc-800"
|
||||||
|
>Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-red-600 px-4 py-2 font-medium text-white shadow-lg shadow-red-900/20 transition-colors hover:bg-red-700"
|
||||||
|
>Delete</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
79
src/lib/components/FolderModal.svelte
Normal file
79
src/lib/components/FolderModal.svelte
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
export let show = false;
|
||||||
|
export let folder: any = null; // If provided, we are editing
|
||||||
|
export let parentId: string | null = null; // For creating new folder
|
||||||
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
|
||||||
|
$: if (show && dialog && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
} else if (!show && dialog && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
class="m-auto w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-950 p-0 text-white shadow-2xl backdrop:bg-black/80"
|
||||||
|
on:close={close}
|
||||||
|
>
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="mb-4 text-lg font-bold text-white">
|
||||||
|
{folder ? 'Rename Folder' : 'Create New Folder'}
|
||||||
|
</h2>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={folder ? '?/renameFolder' : '?/createFolder'}
|
||||||
|
class="space-y-4"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
close();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if folder}
|
||||||
|
<input type="hidden" name="id" value={folder.id} />
|
||||||
|
{:else}
|
||||||
|
<input type="hidden" name="parentId" value={parentId || ''} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="folderName" class="mb-1 block text-sm font-medium text-zinc-400"
|
||||||
|
>Folder Name</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
id="folderName"
|
||||||
|
required
|
||||||
|
value={folder?.name || ''}
|
||||||
|
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white placeholder-zinc-600 transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
|
||||||
|
placeholder="e.g. Components"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={close}
|
||||||
|
class="rounded-md px-4 py-2 font-medium text-zinc-400 transition-colors hover:bg-zinc-800"
|
||||||
|
>Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-red-600 px-4 py-2 font-medium text-white shadow-lg shadow-red-900/20 transition-colors hover:bg-red-700"
|
||||||
|
>{folder ? 'Save Changes' : 'Create Folder'}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
585
src/lib/components/ItemModal.svelte
Normal file
585
src/lib/components/ItemModal.svelte
Normal file
@ -0,0 +1,585 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { propertyConfig } from '$lib/config/properties';
|
||||||
|
import { formatValue } from '$lib/utils/units';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let show = false;
|
||||||
|
export let item: any = null;
|
||||||
|
export let categories: any[] = [];
|
||||||
|
export let folders: any[] = []; // For autocomplete
|
||||||
|
export let currentFolderId: string | null = null;
|
||||||
|
export let currentCategoryId: string | null = null;
|
||||||
|
export let user: any = null; // Current user
|
||||||
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
let selectedCategoryId = '';
|
||||||
|
let folderSearch = '';
|
||||||
|
let showFolderSuggestions = false;
|
||||||
|
let arbitraryProperties: { key: string; value: string }[] = [];
|
||||||
|
|
||||||
|
// Data for view mode
|
||||||
|
let reservations: any[] = [];
|
||||||
|
let comments: any[] = [];
|
||||||
|
let loadingDetails = false;
|
||||||
|
|
||||||
|
$: if (show && dialog && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
if (item) {
|
||||||
|
selectedCategoryId = item.categoryId;
|
||||||
|
// Parse arbitrary properties from item.properties
|
||||||
|
arbitraryProperties = Object.entries(item.properties || {})
|
||||||
|
.filter(
|
||||||
|
([key]) =>
|
||||||
|
!categories.find((c) => c.id === item.categoryId)?.defaultProperties.includes(key)
|
||||||
|
)
|
||||||
|
.map(([key, value]) => ({ key, value: String(getDisplayValue(key, value)) }));
|
||||||
|
|
||||||
|
if (!currentFolderId) {
|
||||||
|
const folder = folders.find((f) => f.id === item.folderId);
|
||||||
|
if (folder) folderSearch = folder.name;
|
||||||
|
}
|
||||||
|
fetchDetails();
|
||||||
|
} else {
|
||||||
|
selectedCategoryId = currentCategoryId || '';
|
||||||
|
arbitraryProperties = [];
|
||||||
|
folderSearch = '';
|
||||||
|
reservations = [];
|
||||||
|
comments = [];
|
||||||
|
}
|
||||||
|
} else if (!show && dialog && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: selectedCategory = categories.find((c) => c.id === selectedCategoryId);
|
||||||
|
|
||||||
|
// Filter folders for autocomplete
|
||||||
|
$: filteredFolders = folderSearch
|
||||||
|
? folders.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase())).slice(0, 5)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
async function fetchDetails(showLoading = true) {
|
||||||
|
if (!item) return;
|
||||||
|
if (showLoading) loadingDetails = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/items/${item.id}/details`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
reservations = data.reservations;
|
||||||
|
comments = data.comments;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch details', e);
|
||||||
|
} finally {
|
||||||
|
if (showLoading) loadingDetails = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayValue(key: string, value: any) {
|
||||||
|
if (propertyConfig[key]) {
|
||||||
|
return formatValue(value, propertyConfig[key], { includeUnit: false });
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFolder(folder: any) {
|
||||||
|
folderSearch = folder.name;
|
||||||
|
showFolderSuggestions = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProperty() {
|
||||||
|
arbitraryProperties = [...arbitraryProperties, { key: '', value: '' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeProperty(index: number) {
|
||||||
|
arbitraryProperties = arbitraryProperties.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to find user's reservation
|
||||||
|
$: userReservation = user ? reservations.find((r) => r.userId === user.id) : null;
|
||||||
|
|
||||||
|
// Edit state for reservation count
|
||||||
|
let editingReservationId: string | null = null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
class="m-auto w-full max-w-4xl rounded-lg border border-zinc-700 bg-zinc-950 p-0 text-white shadow-2xl backdrop:bg-black/80"
|
||||||
|
on:close={close}
|
||||||
|
on:click={(e) => {
|
||||||
|
if (e.target === dialog) close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="relative flex h-[80vh] flex-col md:flex-row">
|
||||||
|
<button
|
||||||
|
on:click={close}
|
||||||
|
class="absolute top-4 right-4 z-50 rounded-md p-1 text-zinc-400 hover:bg-zinc-800 hover:text-white"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Left Side: Item Details (Edit/Create) -->
|
||||||
|
<div
|
||||||
|
class="flex-1 overflow-y-auto p-6 {item
|
||||||
|
? 'border-b border-zinc-800 md:border-r md:border-b-0'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<h2 class="mb-4 text-lg font-bold text-white">{item ? 'Edit Item' : 'Create New Item'}</h2>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={item ? '?/updateItem' : '?/createItem'}
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
await update();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
{#if item}
|
||||||
|
<input type="hidden" name="id" value={item.id} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label for="itemName" class="mb-1 block text-sm font-medium text-zinc-400"
|
||||||
|
>Item Name</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
id="itemName"
|
||||||
|
value={item?.name || ''}
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white placeholder-zinc-600 transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
|
||||||
|
placeholder="e.g. 10k Resistor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Folder Selection (only if not in a specific folder) -->
|
||||||
|
{#if !currentFolderId}
|
||||||
|
<div class="relative">
|
||||||
|
<label for="folderName" class="mb-1 block text-sm font-medium text-zinc-400"
|
||||||
|
>Location</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="folderName"
|
||||||
|
name="folderName"
|
||||||
|
bind:value={folderSearch}
|
||||||
|
on:focus={() => (showFolderSuggestions = true)}
|
||||||
|
on:input={() => (showFolderSuggestions = true)}
|
||||||
|
on:keydown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown' && filteredFolders.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const firstBtn = document.getElementById('folder-suggestion-0');
|
||||||
|
firstBtn?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
on:blur={(e) => {
|
||||||
|
// Check if the related target is one of our suggestion buttons
|
||||||
|
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||||
|
if (!relatedTarget?.closest('.folder-suggestions')) {
|
||||||
|
setTimeout(() => (showFolderSuggestions = false), 200);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white placeholder-zinc-600 transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
|
||||||
|
placeholder="Search for a location..."
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="folderId"
|
||||||
|
value={folders.find((f) => f.name === folderSearch)?.id || ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if showFolderSuggestions && filteredFolders.length > 0}
|
||||||
|
<div
|
||||||
|
class="folder-suggestions absolute z-10 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-zinc-700 bg-zinc-900 shadow-lg"
|
||||||
|
>
|
||||||
|
{#each filteredFolders as folder, i}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="folder-suggestion-{i}"
|
||||||
|
class="w-full px-4 py-2 text-left text-sm text-zinc-300 hover:bg-zinc-800 focus:bg-zinc-800 focus:outline-none"
|
||||||
|
on:click={() => selectFolder(folder)}
|
||||||
|
>
|
||||||
|
{folder.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<input type="hidden" name="folderId" value={currentFolderId} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="itemCount" class="mb-1 block text-sm font-medium text-zinc-400">Count</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="count"
|
||||||
|
id="itemCount"
|
||||||
|
required
|
||||||
|
min="0"
|
||||||
|
value={item?.count || 1}
|
||||||
|
class="no-spin w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="categoryId" class="mb-1 block text-sm font-medium text-zinc-400"
|
||||||
|
>Category</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
name="categoryId"
|
||||||
|
id="categoryId"
|
||||||
|
bind:value={selectedCategoryId}
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
|
||||||
|
>
|
||||||
|
<option value="" disabled selected>Select Category</option>
|
||||||
|
{#each categories as cat}
|
||||||
|
<option value={cat.id}>{cat.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedCategory}
|
||||||
|
<div class="mt-4 border-t border-zinc-700 pt-4">
|
||||||
|
<h3 class="mb-3 text-sm font-semibold text-zinc-300">Category Properties</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
{#each selectedCategory.defaultProperties as prop}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="prop_{prop}"
|
||||||
|
class="mb-1 block text-xs font-medium text-zinc-500 capitalize">{prop}</label
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="prop_{prop}"
|
||||||
|
id="prop_{prop}"
|
||||||
|
value={item?.properties ? getDisplayValue(prop, item.properties[prop]) : ''}
|
||||||
|
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
|
||||||
|
/>
|
||||||
|
{#if propertyConfig[prop]}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-zinc-500"
|
||||||
|
>
|
||||||
|
{propertyConfig[prop].unit}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-4 border-t border-zinc-700 pt-4">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-zinc-300">Additional Properties</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={addProperty}
|
||||||
|
class="text-xs font-medium text-red-500 hover:text-red-400"
|
||||||
|
>
|
||||||
|
+ Add Property
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each arbitraryProperties as prop, i}
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="custom_key_{i}"
|
||||||
|
bind:value={prop.key}
|
||||||
|
placeholder="Name"
|
||||||
|
class="w-1/3 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 outline-none focus:ring-1 focus:ring-red-500"
|
||||||
|
/>
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="custom_val_{i}"
|
||||||
|
bind:value={prop.value}
|
||||||
|
placeholder="Value"
|
||||||
|
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 outline-none focus:ring-1 focus:ring-red-500"
|
||||||
|
/>
|
||||||
|
{#if propertyConfig[prop.key]}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-zinc-500"
|
||||||
|
>
|
||||||
|
{propertyConfig[prop.key].unit}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={() => removeProperty(i)}
|
||||||
|
class="mt-2 px-2 text-zinc-600 hover:text-red-500"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={close}
|
||||||
|
class="rounded-md px-4 py-2 font-medium text-zinc-400 transition-colors hover:bg-zinc-800"
|
||||||
|
>Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-red-600 px-4 py-2 font-medium text-white shadow-lg shadow-red-900/20 transition-colors hover:bg-red-700"
|
||||||
|
>{item ? 'Save Changes' : 'Create Item'}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Side: Reservations & Comments (Only in Edit Mode) -->
|
||||||
|
{#if item}
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden bg-zinc-900/30">
|
||||||
|
<!-- Reservations Section -->
|
||||||
|
<div class="flex-1 overflow-y-auto border-b border-zinc-800 p-6">
|
||||||
|
<h3 class="mb-4 text-lg font-bold text-white">Reservations</h3>
|
||||||
|
|
||||||
|
{#if loadingDetails}
|
||||||
|
<div class="text-sm text-zinc-500">Loading...</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each reservations as reservation}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-900 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-800 text-xs font-bold text-zinc-400"
|
||||||
|
>
|
||||||
|
{reservation.user?.username?.[0]?.toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-white">
|
||||||
|
{reservation.user?.username || 'Unknown User'}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">
|
||||||
|
{new Date(reservation.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if user && reservation.userId === user.id}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<form
|
||||||
|
action="/items?/updateReservation"
|
||||||
|
method="POST"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') fetchDetails(false);
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={reservation.id} />
|
||||||
|
<input type="hidden" name="count" value={reservation.count - 1} />
|
||||||
|
<button
|
||||||
|
class="h-6 w-6 rounded bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-white"
|
||||||
|
>-</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if editingReservationId === reservation.id}
|
||||||
|
<form
|
||||||
|
action="/items?/updateReservation"
|
||||||
|
method="POST"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
editingReservationId = null;
|
||||||
|
fetchDetails(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={reservation.id} />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="count"
|
||||||
|
value={reservation.count}
|
||||||
|
class="w-12 rounded bg-zinc-800 px-1 text-center text-sm font-bold text-white outline-none focus:ring-1 focus:ring-red-500"
|
||||||
|
autofocus
|
||||||
|
on:blur={(e) => e.currentTarget.form?.requestSubmit()}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="min-w-[1.5rem] text-center text-sm font-bold text-white hover:text-red-400"
|
||||||
|
on:click={() => (editingReservationId = reservation.id)}
|
||||||
|
>
|
||||||
|
{reservation.count}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form
|
||||||
|
action="/items?/updateReservation"
|
||||||
|
method="POST"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') fetchDetails(false);
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={reservation.id} />
|
||||||
|
<input type="hidden" name="count" value={reservation.count + 1} />
|
||||||
|
<button
|
||||||
|
class="h-6 w-6 rounded bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-white"
|
||||||
|
>+</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="rounded bg-zinc-800 px-2 py-1 text-sm font-medium text-zinc-300"
|
||||||
|
>{reservation.count} reserved</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if reservations.length === 0}
|
||||||
|
<div class="text-sm text-zinc-500 italic">No reservations yet.</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if user && !userReservation}
|
||||||
|
<div class="mt-4">
|
||||||
|
<form
|
||||||
|
action="/items?/reserve"
|
||||||
|
method="POST"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') fetchDetails(false);
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="itemId" value={item.id} />
|
||||||
|
<input type="hidden" name="count" value="1" />
|
||||||
|
<button
|
||||||
|
class="w-full rounded-md border border-zinc-700 bg-zinc-800 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700 hover:text-white"
|
||||||
|
>
|
||||||
|
Reserve Item
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments Section -->
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden p-6">
|
||||||
|
<h3 class="mb-4 text-lg font-bold text-white">Comments</h3>
|
||||||
|
|
||||||
|
<div class="mb-4 flex-1 space-y-4 overflow-y-auto pr-2">
|
||||||
|
{#if loadingDetails}
|
||||||
|
<div class="text-sm text-zinc-500">Loading...</div>
|
||||||
|
{:else}
|
||||||
|
{#each comments as comment}
|
||||||
|
<div class="group relative rounded-md bg-zinc-900 p-3">
|
||||||
|
<div class="mb-1 flex items-center justify-between pr-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-bold text-zinc-300"
|
||||||
|
>{comment.user?.username || 'Unknown'}</span
|
||||||
|
>
|
||||||
|
<span class="text-[10px] text-zinc-600"
|
||||||
|
>{new Date(comment.createdAt).toLocaleDateString()}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-zinc-400">{comment.text}</p>
|
||||||
|
|
||||||
|
{#if user && comment.userId === user.id}
|
||||||
|
<form
|
||||||
|
action="/items?/deleteComment"
|
||||||
|
method="POST"
|
||||||
|
class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') fetchDetails(false);
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={comment.id} />
|
||||||
|
<button class="text-zinc-600 hover:text-red-500" title="Delete">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if comments.length === 0}
|
||||||
|
<div class="text-sm text-zinc-500 italic">No comments yet.</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if user}
|
||||||
|
<form
|
||||||
|
action="/items?/comment"
|
||||||
|
method="POST"
|
||||||
|
class="flex gap-2"
|
||||||
|
use:enhance={({ formElement }) => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
formElement.reset();
|
||||||
|
fetchDetails(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="itemId" value={item.id} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="text"
|
||||||
|
required
|
||||||
|
placeholder="Write a comment..."
|
||||||
|
class="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-zinc-800 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
149
src/lib/components/MoveItemModal.svelte
Normal file
149
src/lib/components/MoveItemModal.svelte
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
export let show = false;
|
||||||
|
export let itemIds: string[] = [];
|
||||||
|
export let itemName: string | null = null; // For single item display
|
||||||
|
export let folders: any[] = [];
|
||||||
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
export let type: 'item' | 'folder' = 'item';
|
||||||
|
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
let folderSearch = '';
|
||||||
|
let showFolderSuggestions = false;
|
||||||
|
let selectedFolderId = '';
|
||||||
|
|
||||||
|
$: if (show && dialog && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
folderSearch = '';
|
||||||
|
selectedFolderId = '';
|
||||||
|
} else if (!show && dialog && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter folders for autocomplete
|
||||||
|
$: filteredFolders = folderSearch
|
||||||
|
? folders.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase())).slice(0, 5)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
function selectFolder(folder: any) {
|
||||||
|
folderSearch = folder.name;
|
||||||
|
selectedFolderId = folder.id;
|
||||||
|
showFolderSuggestions = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
class="m-auto w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-950 p-0 text-white shadow-2xl backdrop:bg-black/80"
|
||||||
|
on:close={close}
|
||||||
|
>
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="mb-4 text-lg font-bold text-white">
|
||||||
|
Move {itemIds.length > 1
|
||||||
|
? type === 'folder'
|
||||||
|
? 'Folders'
|
||||||
|
: 'Items'
|
||||||
|
: type === 'folder'
|
||||||
|
? 'Folder'
|
||||||
|
: 'Item'}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-4 text-sm text-zinc-400">
|
||||||
|
{#if itemIds.length === 1 && itemName}
|
||||||
|
Move <span class="font-medium text-white">{itemName}</span> to a different folder.
|
||||||
|
{:else}
|
||||||
|
Move <span class="font-medium text-white"
|
||||||
|
>{itemIds.length}
|
||||||
|
{type === 'folder'
|
||||||
|
? itemIds.length > 1
|
||||||
|
? 'folders'
|
||||||
|
: 'folder'
|
||||||
|
: itemIds.length > 1
|
||||||
|
? 'items'
|
||||||
|
: 'item'}</span
|
||||||
|
> to a different folder.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={type === 'folder' ? '?/moveFolder' : '?/bulkMove'}
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
close();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
{#if type === 'folder'}
|
||||||
|
<input type="hidden" name="id" value={itemIds[0]} />
|
||||||
|
{:else}
|
||||||
|
<input type="hidden" name="ids" value={JSON.stringify(itemIds)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<label for="moveFolderName" class="mb-1 block text-sm font-medium text-zinc-400"
|
||||||
|
>Destination Folder</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="moveFolderName"
|
||||||
|
name="folderName"
|
||||||
|
bind:value={folderSearch}
|
||||||
|
on:focus={() => (showFolderSuggestions = true)}
|
||||||
|
on:input={() => (showFolderSuggestions = true)}
|
||||||
|
on:blur={() => setTimeout(() => (showFolderSuggestions = false), 200)}
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white placeholder-zinc-600 transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
|
||||||
|
placeholder="Search for a location..."
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="folderId" value={selectedFolderId} />
|
||||||
|
|
||||||
|
{#if showFolderSuggestions && filteredFolders.length > 0}
|
||||||
|
<div
|
||||||
|
class="absolute z-10 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-zinc-700 bg-zinc-900 shadow-lg"
|
||||||
|
>
|
||||||
|
{#each filteredFolders as folder}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-4 py-2 text-left text-sm text-zinc-300 hover:bg-zinc-800"
|
||||||
|
on:click={() => selectFolder(folder)}
|
||||||
|
>
|
||||||
|
{folder.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={close}
|
||||||
|
class="rounded-md px-4 py-2 font-medium text-zinc-400 transition-colors hover:bg-zinc-800"
|
||||||
|
>Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!selectedFolderId}
|
||||||
|
class="rounded-md bg-red-600 px-4 py-2 font-medium text-white shadow-lg shadow-red-900/20 transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>Move {itemIds.length > 1
|
||||||
|
? type === 'folder'
|
||||||
|
? 'Folders'
|
||||||
|
: 'Items'
|
||||||
|
: type === 'folder'
|
||||||
|
? 'Folder'
|
||||||
|
: 'Item'}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
107
src/lib/components/Search.svelte
Normal file
107
src/lib/components/Search.svelte
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
let query = '';
|
||||||
|
let suggestions: any[] = [];
|
||||||
|
let showSuggestions = false;
|
||||||
|
let timeout: NodeJS.Timeout;
|
||||||
|
|
||||||
|
async function fetchSuggestions() {
|
||||||
|
if (query.length < 1) {
|
||||||
|
suggestions = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||||
|
if (res.ok) {
|
||||||
|
suggestions = await res.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
fetchSuggestions();
|
||||||
|
showSuggestions = true;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
showSuggestions = false;
|
||||||
|
goto(`/folders?search=${encodeURIComponent(query)}`, { keepFocus: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSuggestion(suggestion: any) {
|
||||||
|
query = suggestion.name;
|
||||||
|
showSuggestions = false;
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
if ($page.url.pathname !== '/folders') {
|
||||||
|
goto('/folders', { keepFocus: true });
|
||||||
|
}
|
||||||
|
if (query.length > 0) {
|
||||||
|
showSuggestions = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.search-container')) {
|
||||||
|
showSuggestions = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:click={handleClickOutside} />
|
||||||
|
|
||||||
|
<div class="search-container relative w-full max-w-md">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 text-zinc-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
id="default-search"
|
||||||
|
class="block w-full rounded-lg border border-zinc-700 bg-zinc-900 p-2.5 pl-10 text-sm text-white placeholder-zinc-400 focus:border-red-500 focus:ring-red-500"
|
||||||
|
placeholder="Search items..."
|
||||||
|
bind:value={query}
|
||||||
|
on:input={handleInput}
|
||||||
|
on:focus={handleFocus}
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showSuggestions && suggestions.length > 0}
|
||||||
|
<div class="absolute z-50 mt-1 w-full rounded-lg border border-zinc-700 bg-zinc-900 shadow-lg">
|
||||||
|
<ul class="py-1 text-sm text-zinc-200">
|
||||||
|
{#each suggestions as suggestion}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="block w-full px-4 py-2 text-left hover:bg-zinc-800 hover:text-white"
|
||||||
|
on:click={() => selectSuggestion(suggestion)}
|
||||||
|
>
|
||||||
|
{suggestion.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
88
src/lib/components/Sidebar.svelte
Normal file
88
src/lib/components/Sidebar.svelte
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="fixed top-0 left-0 flex h-screen w-64 flex-col overflow-y-auto border-r border-zinc-800 bg-black text-white"
|
||||||
|
>
|
||||||
|
<div class="border-b border-zinc-800 p-6">
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight text-white">
|
||||||
|
<span class="font-extrabold text-yellow-500">EL</span> Inventory
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex-1 space-y-2 p-4">
|
||||||
|
<a
|
||||||
|
href="/folders"
|
||||||
|
class="block rounded-md px-4 py-2 transition-all duration-200 {$page.url.pathname.startsWith(
|
||||||
|
'/folders'
|
||||||
|
)
|
||||||
|
? 'bg-red-600 text-white shadow-lg shadow-red-900/20'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-900 hover:text-white'}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium">Locations</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/categories"
|
||||||
|
class="block rounded-md px-4 py-2 transition-all duration-200 {$page.url.pathname.startsWith(
|
||||||
|
'/categories'
|
||||||
|
)
|
||||||
|
? 'bg-red-600 text-white shadow-lg shadow-red-900/20'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-900 hover:text-white'}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium">Categories</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="mt-2 ml-4 space-y-1 border-l border-zinc-800 pl-4">
|
||||||
|
{#if $page.data.categories}
|
||||||
|
{#each $page.data.categories as category}
|
||||||
|
<a
|
||||||
|
href="/categories/{category.id}"
|
||||||
|
class="block rounded-md px-2 py-1.5 text-sm transition-all duration-200 {$page.url
|
||||||
|
.pathname === `/categories/${category.id}`
|
||||||
|
? 'text-red-500'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="border-t border-zinc-800 p-4">
|
||||||
|
<div class="text-center text-xs text-gray-500">v0.1.0 Beta</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
15
src/lib/config/properties.ts
Normal file
15
src/lib/config/properties.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export interface PropertyConfig {
|
||||||
|
unit: string;
|
||||||
|
prefixes: string[];
|
||||||
|
base: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const propertyConfig: Record<string, PropertyConfig> = {
|
||||||
|
Voltage: { unit: 'V', prefixes: ['m', '', 'k', 'M'], base: 'm' },
|
||||||
|
Capacitance: { unit: 'F', prefixes: ['p', 'n', 'u', 'm'], base: 'p' },
|
||||||
|
Resistance: { unit: 'Ω', prefixes: ['', 'k', 'M'], base: '' },
|
||||||
|
Current: { unit: 'A', prefixes: ['u', 'm', '', 'k'], base: 'u' },
|
||||||
|
Power: { unit: 'W', prefixes: ['m', '', 'k'], base: 'm' },
|
||||||
|
Frequency: { unit: 'Hz', prefixes: ['', 'k', 'M', 'G'], base: '' },
|
||||||
|
Inductance: { unit: 'H', prefixes: ['n', 'u', 'm', ''], base: 'n' }
|
||||||
|
};
|
||||||
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
44
src/lib/server/auth.ts
Normal file
44
src/lib/server/auth.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Lucia } from 'lucia';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle';
|
||||||
|
import { db } from './db';
|
||||||
|
import { sessions, users } from './db/schema';
|
||||||
|
import { Gitea } from 'arctic';
|
||||||
|
import { GITEA_BASE_URL, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET } from '$env/static/private';
|
||||||
|
|
||||||
|
const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users);
|
||||||
|
|
||||||
|
export const lucia = new Lucia(adapter, {
|
||||||
|
sessionCookie: {
|
||||||
|
attributes: {
|
||||||
|
secure: !dev
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getUserAttributes: (attributes) => {
|
||||||
|
return {
|
||||||
|
username: attributes.username,
|
||||||
|
giteaId: attributes.giteaId,
|
||||||
|
avatarUrl: attributes.avatarUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module 'lucia' {
|
||||||
|
interface Register {
|
||||||
|
Lucia: typeof lucia;
|
||||||
|
DatabaseUserAttributes: DatabaseUserAttributes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DatabaseUserAttributes {
|
||||||
|
username: string;
|
||||||
|
giteaId: number;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gitea = new Gitea(
|
||||||
|
GITEA_BASE_URL,
|
||||||
|
GITEA_CLIENT_ID,
|
||||||
|
GITEA_CLIENT_SECRET,
|
||||||
|
'http://localhost:5173/login/gitea/callback'
|
||||||
|
);
|
||||||
27
src/lib/server/comments.ts
Normal file
27
src/lib/server/comments.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { comments } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function createComment(itemId: string, userId: string, text: string) {
|
||||||
|
return await db.insert(comments).values({
|
||||||
|
itemId,
|
||||||
|
userId,
|
||||||
|
text
|
||||||
|
}).returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteComment(id: string) {
|
||||||
|
return await db.delete(comments)
|
||||||
|
.where(eq(comments.id, id))
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getComments(itemId: string) {
|
||||||
|
return await db.query.comments.findMany({
|
||||||
|
where: eq(comments.itemId, itemId),
|
||||||
|
with: {
|
||||||
|
user: true
|
||||||
|
},
|
||||||
|
orderBy: (comments, { asc }) => [asc(comments.createdAt)]
|
||||||
|
});
|
||||||
|
}
|
||||||
9
src/lib/server/db/index.ts
Normal file
9
src/lib/server/db/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||||
|
|
||||||
|
const client = postgres(env.DATABASE_URL);
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
105
src/lib/server/db/schema.ts
Normal file
105
src/lib/server/db/schema.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { pgTable, uuid, text, integer, jsonb, timestamp, foreignKey } from 'drizzle-orm/pg-core';
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const folders = pgTable('folders', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
parentId: uuid('parent_id'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull()
|
||||||
|
}, (table) => {
|
||||||
|
return {
|
||||||
|
parentReference: foreignKey({
|
||||||
|
columns: [table.parentId],
|
||||||
|
foreignColumns: [table.id],
|
||||||
|
name: 'folders_parent_id_fkey'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const categories = pgTable('categories', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
defaultProperties: jsonb('default_properties').$type<string[]>().default([]).notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const items = pgTable('items', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
folderId: uuid('folder_id').references(() => folders.id).notNull(),
|
||||||
|
categoryId: uuid('category_id').references(() => categories.id).notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
count: integer('count').default(0).notNull(),
|
||||||
|
tags: jsonb('tags').$type<string[]>().default([]).notNull(),
|
||||||
|
properties: jsonb('properties').$type<Record<string, any>>().default({}).notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
giteaId: integer('gitea_id').unique(),
|
||||||
|
username: text('username').notNull(),
|
||||||
|
avatarUrl: text('avatar_url')
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessions = pgTable('sessions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reservations = pgTable('reservations', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
itemId: uuid('item_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => items.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
count: integer('count').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const comments = pgTable('comments', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
itemId: uuid('item_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => items.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
text: text('text').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemsRelations = relations(items, ({ many }) => ({
|
||||||
|
reservations: many(reservations),
|
||||||
|
comments: many(comments)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const reservationsRelations = relations(reservations, ({ one }) => ({
|
||||||
|
item: one(items, {
|
||||||
|
fields: [reservations.itemId],
|
||||||
|
references: [items.id]
|
||||||
|
}),
|
||||||
|
user: one(users, {
|
||||||
|
fields: [reservations.userId],
|
||||||
|
references: [users.id]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const commentsRelations = relations(comments, ({ one }) => ({
|
||||||
|
item: one(items, {
|
||||||
|
fields: [comments.itemId],
|
||||||
|
references: [items.id]
|
||||||
|
}),
|
||||||
|
user: one(users, {
|
||||||
|
fields: [comments.userId],
|
||||||
|
references: [users.id]
|
||||||
|
})
|
||||||
|
}));
|
||||||
37
src/lib/server/reservations.ts
Normal file
37
src/lib/server/reservations.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { reservations } from '$lib/server/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function createReservation(itemId: string, userId: string, count: number) {
|
||||||
|
return await db.insert(reservations).values({
|
||||||
|
itemId,
|
||||||
|
userId,
|
||||||
|
count
|
||||||
|
}).returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateReservation(id: string, count: number) {
|
||||||
|
if (count <= 0) {
|
||||||
|
return await deleteReservation(id);
|
||||||
|
}
|
||||||
|
return await db.update(reservations)
|
||||||
|
.set({ count, updatedAt: new Date() })
|
||||||
|
.where(eq(reservations.id, id))
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteReservation(id: string) {
|
||||||
|
return await db.delete(reservations)
|
||||||
|
.where(eq(reservations.id, id))
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReservations(itemId: string) {
|
||||||
|
return await db.query.reservations.findMany({
|
||||||
|
where: eq(reservations.itemId, itemId),
|
||||||
|
with: {
|
||||||
|
user: true // Assuming we want user details, need to check relations
|
||||||
|
},
|
||||||
|
orderBy: (reservations, { desc }) => [desc(reservations.createdAt)]
|
||||||
|
});
|
||||||
|
}
|
||||||
36
src/lib/utils/propertyUtils.ts
Normal file
36
src/lib/utils/propertyUtils.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { propertyConfig } from '$lib/config/properties';
|
||||||
|
import { parseValue } from '$lib/utils/units';
|
||||||
|
|
||||||
|
export function processProperties(data: FormData): Record<string, any> {
|
||||||
|
const properties: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 1. Category default properties
|
||||||
|
for (const [key, value] of data.entries()) {
|
||||||
|
if (key.startsWith('prop_')) {
|
||||||
|
const propName = key.replace('prop_', '');
|
||||||
|
const propValue = value as string;
|
||||||
|
|
||||||
|
if (propertyConfig[propName]) {
|
||||||
|
properties[propName] = parseValue(propValue, propertyConfig[propName]);
|
||||||
|
} else {
|
||||||
|
properties[propName] = propValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Arbitrary properties
|
||||||
|
let i = 0;
|
||||||
|
while (data.has(`custom_key_${i}`)) {
|
||||||
|
const key = data.get(`custom_key_${i}`) as string;
|
||||||
|
const value = data.get(`custom_val_${i}`) as string;
|
||||||
|
if (key) {
|
||||||
|
if (propertyConfig[key]) {
|
||||||
|
properties[key] = parseValue(value, propertyConfig[key]);
|
||||||
|
} else {
|
||||||
|
properties[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
53
src/lib/utils/units.test.ts
Normal file
53
src/lib/utils/units.test.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseValue, formatValue } from './units';
|
||||||
|
import { propertyConfig } from '../config/properties';
|
||||||
|
|
||||||
|
describe('Unit Utils', () => {
|
||||||
|
const voltageConfig = propertyConfig['Voltage']; // base: 'm' (mV)
|
||||||
|
const capacitanceConfig = propertyConfig['Capacitance']; // base: 'p' (pF)
|
||||||
|
|
||||||
|
describe('parseValue', () => {
|
||||||
|
it('should parse simple values', () => {
|
||||||
|
expect(parseValue('10', voltageConfig)).toBe(10000); // 10V = 10000mV (Wait, 10 with no prefix means base unit? No, usually means standard unit 'V' i.e. prefix '')
|
||||||
|
// Let's check logic: prefix '' multiplier is 1. base 'm' is 1e-3. 1 / 1e-3 = 1000. Correct.
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse values with prefixes', () => {
|
||||||
|
expect(parseValue('10k', voltageConfig)).toBe(10000000); // 10kV = 10,000,000mV
|
||||||
|
expect(parseValue('100m', voltageConfig)).toBe(100); // 100mV = 100mV
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse values with unit suffix', () => {
|
||||||
|
expect(parseValue('10kV', voltageConfig)).toBe(10000000);
|
||||||
|
expect(parseValue('10V', voltageConfig)).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle capacitance', () => {
|
||||||
|
expect(parseValue('10u', capacitanceConfig)).toBe(1000000); // 10uF = 1,000,000pF
|
||||||
|
expect(parseValue('100n', capacitanceConfig)).toBe(100000); // 100nF = 100,000pF
|
||||||
|
expect(parseValue('1p', capacitanceConfig)).toBe(1); // 1pF = 1pF
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original string for invalid input', () => {
|
||||||
|
expect(parseValue('abc', voltageConfig)).toBe('abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatValue', () => {
|
||||||
|
it('should format values to appropriate prefix', () => {
|
||||||
|
expect(formatValue(10000, voltageConfig)).toBe('10V'); // 10000mV = 10V
|
||||||
|
expect(formatValue(10000000, voltageConfig)).toBe('10kV'); // 10,000,000mV = 10kV
|
||||||
|
expect(formatValue(100, voltageConfig)).toBe('100mV'); // 100mV = 100mV
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format capacitance', () => {
|
||||||
|
expect(formatValue(1000000, capacitanceConfig)).toBe('1uF'); // 1,000,000pF = 1uF
|
||||||
|
expect(formatValue(100000, capacitanceConfig)).toBe('100nF'); // 100,000pF = 100nF
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect includeUnit option', () => {
|
||||||
|
expect(formatValue(10000, voltageConfig, { includeUnit: false })).toBe('10');
|
||||||
|
expect(formatValue(10000000, voltageConfig, { includeUnit: false })).toBe('10k');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
83
src/lib/utils/units.ts
Normal file
83
src/lib/utils/units.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import type { PropertyConfig } from '../config/properties';
|
||||||
|
|
||||||
|
const PREFIX_MULTIPLIERS: Record<string, number> = {
|
||||||
|
'p': 1e-12,
|
||||||
|
'n': 1e-9,
|
||||||
|
'u': 1e-6,
|
||||||
|
'm': 1e-3,
|
||||||
|
'': 1,
|
||||||
|
'k': 1e3,
|
||||||
|
'M': 1e6,
|
||||||
|
'G': 1e9,
|
||||||
|
'T': 1e12
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseValue(value: string, config: PropertyConfig): number | string {
|
||||||
|
if (!value) return '';
|
||||||
|
|
||||||
|
// Remove unit if present (case insensitive)
|
||||||
|
const cleanValue = value.replace(new RegExp(config.unit + '$', 'i'), '').trim();
|
||||||
|
|
||||||
|
// Extract number and prefix
|
||||||
|
const match = cleanValue.match(/^([\d.]+)\s*([a-zA-Z]*)$/);
|
||||||
|
if (!match) return value; // Return original if not parseable
|
||||||
|
|
||||||
|
const num = parseFloat(match[1]);
|
||||||
|
const prefix = match[2];
|
||||||
|
|
||||||
|
if (isNaN(num)) return value;
|
||||||
|
|
||||||
|
// Check if prefix is valid for this property (or if it's a standard SI prefix we should handle)
|
||||||
|
// Ideally we only accept prefixes defined in the config or standard ones.
|
||||||
|
// For now let's use the global multiplier map but ensure we map to the base unit.
|
||||||
|
|
||||||
|
const multiplier = PREFIX_MULTIPLIERS[prefix];
|
||||||
|
const baseMultiplier = PREFIX_MULTIPLIERS[config.base];
|
||||||
|
|
||||||
|
if (multiplier === undefined || baseMultiplier === undefined) return value;
|
||||||
|
|
||||||
|
// Calculate value in base units
|
||||||
|
// e.g. 10k (10 * 10^3) -> base m (10^-3)
|
||||||
|
// 10 * 10^3 / 10^-3 = 10 * 10^6 = 10,000,000
|
||||||
|
return Math.round(num * (multiplier / baseMultiplier));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValue(value: number | string, config: PropertyConfig, options: { includeUnit?: boolean } = {}): string {
|
||||||
|
const { includeUnit = true } = options;
|
||||||
|
|
||||||
|
if (typeof value !== 'number') {
|
||||||
|
// Try to parse string as number if it looks like one (e.g. from DB JSON)
|
||||||
|
const num = parseFloat(value as string);
|
||||||
|
if (isNaN(num)) return value as string;
|
||||||
|
value = num;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseMultiplier = PREFIX_MULTIPLIERS[config.base];
|
||||||
|
const absoluteValue = value * baseMultiplier;
|
||||||
|
|
||||||
|
// Find the best prefix
|
||||||
|
// We want the largest prefix where the number is >= 1 (or the smallest prefix if < 1)
|
||||||
|
// But restricted to the allowed prefixes in config.
|
||||||
|
|
||||||
|
let bestPrefix = config.prefixes[0];
|
||||||
|
|
||||||
|
// Sort prefixes by multiplier ascending
|
||||||
|
const sortedPrefixes = [...config.prefixes].sort((a, b) => PREFIX_MULTIPLIERS[a] - PREFIX_MULTIPLIERS[b]);
|
||||||
|
|
||||||
|
for (const prefix of sortedPrefixes) {
|
||||||
|
const multiplier = PREFIX_MULTIPLIERS[prefix];
|
||||||
|
if (absoluteValue >= multiplier) {
|
||||||
|
bestPrefix = prefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If value is 0, use the base unit or the first prefix? Usually just 0 + unit or 0 + base + unit
|
||||||
|
if (value === 0) return `0 ${includeUnit ? config.base + config.unit : config.base}`;
|
||||||
|
|
||||||
|
const displayValue = absoluteValue / PREFIX_MULTIPLIERS[bestPrefix];
|
||||||
|
|
||||||
|
// Round to reasonable decimals (e.g. 3) to avoid float errors
|
||||||
|
const roundedValue = Math.round(displayValue * 1000) / 1000;
|
||||||
|
|
||||||
|
return `${roundedValue}${bestPrefix}${includeUnit ? config.unit : ''}`;
|
||||||
|
}
|
||||||
10
src/routes/+layout.server.ts
Normal file
10
src/routes/+layout.server.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { categories } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
export async function load({ locals }) {
|
||||||
|
const allCategories = await db.select().from(categories);
|
||||||
|
return {
|
||||||
|
categories: allCategories,
|
||||||
|
user: locals.user
|
||||||
|
};
|
||||||
|
}
|
||||||
47
src/routes/+layout.svelte
Normal file
47
src/routes/+layout.svelte
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<script>
|
||||||
|
import '../app.css';
|
||||||
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import Search from '$lib/components/Search.svelte';
|
||||||
|
export let data;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-black font-sans text-white">
|
||||||
|
{#if data.user}
|
||||||
|
<Sidebar />
|
||||||
|
<main class="ml-64 min-h-screen transition-all duration-300">
|
||||||
|
<div class="flex items-center justify-between px-8 pt-4">
|
||||||
|
<div class="flex flex-1 justify-center">
|
||||||
|
<Search />
|
||||||
|
</div>
|
||||||
|
<div class="group relative flex cursor-pointer items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 transition-opacity duration-200 group-hover:opacity-0"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-gray-300">{data.user.username}</span>
|
||||||
|
{#if data.user.avatarUrl}
|
||||||
|
<img
|
||||||
|
src={data.user.avatarUrl}
|
||||||
|
alt={data.user.username}
|
||||||
|
class="h-8 w-8 rounded-full"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-end opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<form method="POST" action="/logout">
|
||||||
|
<button type="submit" class="text-sm font-medium text-red-500 hover:text-red-400">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mx-auto max-w-7xl p-8">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{:else}
|
||||||
|
<slot />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
12
src/routes/+page.svelte
Normal file
12
src/routes/+page.svelte
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
goto('/folders');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center h-screen">
|
||||||
|
<p class="text-zinc-500">Redirecting to Inventory...</p>
|
||||||
|
</div>
|
||||||
19
src/routes/api/categories/+server.ts
Normal file
19
src/routes/api/categories/+server.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { categories } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const result = await db.select().from(categories);
|
||||||
|
return json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ request }) {
|
||||||
|
const { name, defaultProperties } = await request.json();
|
||||||
|
|
||||||
|
const result = await db.insert(categories).values({
|
||||||
|
name,
|
||||||
|
defaultProperties: defaultProperties || []
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return json(result[0]);
|
||||||
|
}
|
||||||
21
src/routes/api/categories/[id]/+server.ts
Normal file
21
src/routes/api/categories/[id]/+server.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { categories } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET({ params }) {
|
||||||
|
const { id } = params;
|
||||||
|
const result = await db.select().from(categories).where(eq(categories.id, id));
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return json({ error: 'Category not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(result[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE({ params }) {
|
||||||
|
const { id } = params;
|
||||||
|
await db.delete(categories).where(eq(categories.id, id));
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
24
src/routes/api/folders/+server.ts
Normal file
24
src/routes/api/folders/+server.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { folders } from '$lib/server/db/schema';
|
||||||
|
import { eq, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET({ url }) {
|
||||||
|
const parentId = url.searchParams.get('parentId');
|
||||||
|
|
||||||
|
const result = await db.select().from(folders)
|
||||||
|
.where(parentId ? eq(folders.parentId, parentId) : isNull(folders.parentId));
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ request }) {
|
||||||
|
const { name, parentId } = await request.json();
|
||||||
|
|
||||||
|
const result = await db.insert(folders).values({
|
||||||
|
name,
|
||||||
|
parentId: parentId || null
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return json(result[0]);
|
||||||
|
}
|
||||||
21
src/routes/api/folders/[id]/+server.ts
Normal file
21
src/routes/api/folders/[id]/+server.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { folders } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET({ params }) {
|
||||||
|
const { id } = params;
|
||||||
|
const result = await db.select().from(folders).where(eq(folders.id, id));
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return json({ error: 'Folder not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(result[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE({ params }) {
|
||||||
|
const { id } = params;
|
||||||
|
await db.delete(folders).where(eq(folders.id, id));
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
35
src/routes/api/items/+server.ts
Normal file
35
src/routes/api/items/+server.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { items } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET({ url }) {
|
||||||
|
const folderId = url.searchParams.get('folderId');
|
||||||
|
const categoryId = url.searchParams.get('categoryId');
|
||||||
|
|
||||||
|
let query = db.select().from(items);
|
||||||
|
|
||||||
|
if (folderId) {
|
||||||
|
query = query.where(eq(items.folderId, folderId));
|
||||||
|
} else if (categoryId) {
|
||||||
|
query = query.where(eq(items.categoryId, categoryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query;
|
||||||
|
return json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ request }) {
|
||||||
|
const payload = await request.json();
|
||||||
|
|
||||||
|
const result = await db.insert(items).values({
|
||||||
|
folderId: payload.folderId,
|
||||||
|
categoryId: payload.categoryId,
|
||||||
|
name: payload.name,
|
||||||
|
count: payload.count || 0,
|
||||||
|
tags: payload.tags || [],
|
||||||
|
properties: payload.properties || {}
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return json(result[0]);
|
||||||
|
}
|
||||||
36
src/routes/api/items/[id]/+server.ts
Normal file
36
src/routes/api/items/[id]/+server.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { items } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET({ params }) {
|
||||||
|
const { id } = params;
|
||||||
|
const result = await db.select().from(items).where(eq(items.id, id));
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return json({ error: 'Item not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(result[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT({ params, request }) {
|
||||||
|
const { id } = params;
|
||||||
|
const payload = await request.json();
|
||||||
|
|
||||||
|
const result = await db.update(items).set({
|
||||||
|
name: payload.name,
|
||||||
|
count: payload.count,
|
||||||
|
tags: payload.tags,
|
||||||
|
properties: payload.properties,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}).where(eq(items.id, id)).returning();
|
||||||
|
|
||||||
|
return json(result[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE({ params }) {
|
||||||
|
const { id } = params;
|
||||||
|
await db.delete(items).where(eq(items.id, id));
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
13
src/routes/api/items/[id]/details/+server.ts
Normal file
13
src/routes/api/items/[id]/details/+server.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { getReservations } from '$lib/server/reservations';
|
||||||
|
import { getComments } from '$lib/server/comments';
|
||||||
|
|
||||||
|
export async function GET({ params }) {
|
||||||
|
const { id } = params;
|
||||||
|
const [reservations, comments] = await Promise.all([
|
||||||
|
getReservations(id),
|
||||||
|
getComments(id)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return json({ reservations, comments });
|
||||||
|
}
|
||||||
23
src/routes/api/search/+server.ts
Normal file
23
src/routes/api/search/+server.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { items } from '$lib/server/db/schema';
|
||||||
|
import { ilike } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET({ url }) {
|
||||||
|
const query = url.searchParams.get('q');
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await db
|
||||||
|
.select({
|
||||||
|
id: items.id,
|
||||||
|
name: items.name
|
||||||
|
})
|
||||||
|
.from(items)
|
||||||
|
.where(ilike(items.name, `%${query}%`))
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
return json(results);
|
||||||
|
}
|
||||||
25
src/routes/categories/+page.server.ts
Normal file
25
src/routes/categories/+page.server.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { categories } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
export async function load() {
|
||||||
|
const allCategories = await db.select().from(categories);
|
||||||
|
return { categories: allCategories };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
create: async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const name = data.get('name') as string;
|
||||||
|
const defaultProperties = (data.get('defaultProperties') as string)
|
||||||
|
.split(',')
|
||||||
|
.map(p => p.trim())
|
||||||
|
.filter(p => p.length > 0);
|
||||||
|
|
||||||
|
await db.insert(categories).values({
|
||||||
|
name,
|
||||||
|
defaultProperties
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
69
src/routes/categories/+page.svelte
Normal file
69
src/routes/categories/+page.svelte
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let data;
|
||||||
|
$: ({ categories } = data);
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Categories</h1>
|
||||||
|
<button on:click={openModal} class="px-3 py-1.5 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700 transition-colors shadow-lg shadow-red-900/20">
|
||||||
|
+ New Category
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog bind:this={dialog} class="p-0 rounded-lg shadow-2xl backdrop:bg-black/80 w-full max-w-md m-auto bg-zinc-950 border border-zinc-700 text-white">
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="text-lg font-bold text-white mb-4">Create New Category</h2>
|
||||||
|
<form method="POST" action="?/create" class="space-y-4" on:submit={closeModal}>
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-zinc-400 mb-1">Category Name</label>
|
||||||
|
<input type="text" name="name" id="name" required class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-md focus:ring-1 focus:ring-red-500 focus:border-red-500 outline-none transition-all text-white placeholder-zinc-600" placeholder="e.g. Resistors" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="defaultProperties" class="block text-sm font-medium text-zinc-400 mb-1">Default Properties (comma separated)</label>
|
||||||
|
<input type="text" name="defaultProperties" id="defaultProperties" class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-md focus:ring-1 focus:ring-red-500 focus:border-red-500 outline-none transition-all text-white placeholder-zinc-600" placeholder="e.g. Resistance, Tolerance, Power Rating" />
|
||||||
|
<p class="text-xs text-zinc-500 mt-1">These properties will be added to all new items in this category.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
<button type="button" on:click={closeModal} class="px-4 py-2 text-zinc-400 hover:bg-zinc-800 rounded-md font-medium transition-colors">Cancel</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-red-600 text-white rounded-md font-medium hover:bg-red-700 transition-colors shadow-lg shadow-red-900/20">Create Category</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{#each categories as category}
|
||||||
|
<a href="/categories/{category.id}" class="group block p-4 bg-zinc-900/50 rounded-md border border-zinc-700 hover:border-red-500/50 hover:shadow-lg transition-all duration-200">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="p-2 bg-zinc-800 text-zinc-400 rounded group-hover:text-white transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium text-zinc-500 group-hover:text-red-500 transition-colors">
|
||||||
|
{category.defaultProperties.length} Props
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-zinc-200 group-hover:text-white transition-colors">{category.name}</h3>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
|
{#each category.defaultProperties.slice(0, 3) as prop}
|
||||||
|
<span class="text-xs text-zinc-400 bg-zinc-800 px-2 py-1 rounded border border-zinc-700">{prop}</span>
|
||||||
|
{/each}
|
||||||
|
{#if category.defaultProperties.length > 3}
|
||||||
|
<span class="text-xs text-zinc-500 px-1">+{category.defaultProperties.length - 3}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
150
src/routes/categories/[id]/+page.server.ts
Normal file
150
src/routes/categories/[id]/+page.server.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { items, folders, categories, reservations } from '$lib/server/db/schema';
|
||||||
|
import { eq, and, isNull, inArray, sql } from 'drizzle-orm';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { processProperties } from '$lib/utils/propertyUtils';
|
||||||
|
|
||||||
|
export const load = async ({ params }: { params: { id: string } }) => {
|
||||||
|
const categoryId = params.id;
|
||||||
|
|
||||||
|
const category = await db.select().from(categories).where(eq(categories.id, categoryId)).limit(1);
|
||||||
|
|
||||||
|
if (category.length === 0) {
|
||||||
|
throw error(404, 'Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryItems = await db.select({
|
||||||
|
id: items.id,
|
||||||
|
name: items.name,
|
||||||
|
count: items.count,
|
||||||
|
categoryName: categories.name,
|
||||||
|
categoryId: items.categoryId,
|
||||||
|
folderId: items.folderId,
|
||||||
|
folderName: folders.name,
|
||||||
|
tags: items.tags,
|
||||||
|
properties: items.properties,
|
||||||
|
reservedCount: sql<number>`(SELECT COALESCE(SUM(count), 0) FROM reservations WHERE item_id = ${items.id})`.mapWith(Number)
|
||||||
|
})
|
||||||
|
.from(items)
|
||||||
|
.leftJoin(categories, eq(items.categoryId, categories.id))
|
||||||
|
.leftJoin(folders, eq(items.folderId, folders.id))
|
||||||
|
.where(eq(items.categoryId, categoryId));
|
||||||
|
|
||||||
|
const allCategories = await db.select().from(categories);
|
||||||
|
const allFolders = await db.select().from(folders);
|
||||||
|
const folderMap = new Map(allFolders.map(f => [f.id, f]));
|
||||||
|
|
||||||
|
const getFolderPath = (folderId: string | null): string => {
|
||||||
|
if (!folderId) return '';
|
||||||
|
const folder = folderMap.get(folderId);
|
||||||
|
if (!folder) return '';
|
||||||
|
const parentPath = getFolderPath(folder.parentId);
|
||||||
|
return parentPath ? `${parentPath}/${folder.name}` : folder.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemsWithPaths = categoryItems.map(item => ({
|
||||||
|
...item,
|
||||||
|
folderPath: item.folderId ? getFolderPath(item.folderId) : null
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
category: category[0],
|
||||||
|
items: itemsWithPaths,
|
||||||
|
categories: allCategories,
|
||||||
|
folders: allFolders
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
createItem: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const name = data.get('name') as string;
|
||||||
|
const folderId = data.get('folderId') as string;
|
||||||
|
const categoryId = data.get('categoryId') as string;
|
||||||
|
const count = parseInt(data.get('count') as string) || 0;
|
||||||
|
|
||||||
|
const properties = processProperties(data);
|
||||||
|
|
||||||
|
await db.insert(items).values({
|
||||||
|
name,
|
||||||
|
folderId: folderId || null,
|
||||||
|
categoryId,
|
||||||
|
count,
|
||||||
|
properties
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
updateItem: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
const name = data.get('name') as string;
|
||||||
|
const categoryId = data.get('categoryId') as string;
|
||||||
|
const count = parseInt(data.get('count') as string) || 0;
|
||||||
|
const folderId = data.get('folderId') as string;
|
||||||
|
|
||||||
|
const properties = processProperties(data);
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
name,
|
||||||
|
categoryId,
|
||||||
|
count,
|
||||||
|
properties,
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (folderId) {
|
||||||
|
updateData.folderId = folderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(items)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(items.id, id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
deleteItem: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
await db.delete(items).where(eq(items.id, id));
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
moveItem: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
const folderId = data.get('folderId') as string;
|
||||||
|
await db.update(items).set({ folderId: folderId || null }).where(eq(items.id, id));
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
bulkDelete: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const ids = JSON.parse(data.get('ids') as string);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await db.delete(items).where(inArray(items.id, ids));
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
bulkMove: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const ids = JSON.parse(data.get('ids') as string);
|
||||||
|
const folderId = data.get('folderId') as string;
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await db.update(items).set({ folderId: folderId || null }).where(inArray(items.id, ids));
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
updateCount: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
const delta = parseInt(data.get('delta') as string) || 0;
|
||||||
|
|
||||||
|
const item = await db.select({ count: items.count }).from(items).where(eq(items.id, id)).limit(1);
|
||||||
|
|
||||||
|
if (item.length > 0) {
|
||||||
|
const newCount = Math.max(0, (item[0].count || 0) + delta);
|
||||||
|
await db.update(items).set({ count: newCount }).where(eq(items.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
451
src/routes/categories/[id]/+page.svelte
Normal file
451
src/routes/categories/[id]/+page.svelte
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import ItemModal from '$lib/components/ItemModal.svelte';
|
||||||
|
import MoveItemModal from '$lib/components/MoveItemModal.svelte';
|
||||||
|
import DeleteConfirmModal from '$lib/components/DeleteConfirmModal.svelte';
|
||||||
|
|
||||||
|
import { propertyConfig } from '$lib/config/properties';
|
||||||
|
import { formatValue } from '$lib/utils/units';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
$: ({ category, items, folders, categories } = data);
|
||||||
|
|
||||||
|
// Item Modal State
|
||||||
|
let showItemModal = false;
|
||||||
|
let showMoveItemModal = false;
|
||||||
|
let showDeleteModal = false;
|
||||||
|
|
||||||
|
let editingItem: any = null;
|
||||||
|
|
||||||
|
// Bulk/Single Action State
|
||||||
|
let selectedItems = new Set<string>();
|
||||||
|
let actionItemIds: string[] = [];
|
||||||
|
let actionItemName: string | null = null; // For display in single item actions
|
||||||
|
|
||||||
|
// Reactive statement to handle selection updates
|
||||||
|
$: allSelected = items.length > 0 && selectedItems.size === items.length;
|
||||||
|
$: isIndeterminate = selectedItems.size > 0 && selectedItems.size < items.length;
|
||||||
|
|
||||||
|
function openCreateItemModal() {
|
||||||
|
editingItem = null;
|
||||||
|
showItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditItemModal(item: any) {
|
||||||
|
editingItem = item;
|
||||||
|
showItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoveItemModal(item: any) {
|
||||||
|
actionItemIds = [item.id];
|
||||||
|
actionItemName = item.name;
|
||||||
|
showMoveItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBulkMoveModal() {
|
||||||
|
actionItemIds = Array.from(selectedItems);
|
||||||
|
actionItemName = null;
|
||||||
|
showMoveItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal(item: any) {
|
||||||
|
actionItemIds = [item.id];
|
||||||
|
actionItemName = item.name;
|
||||||
|
showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBulkDeleteModal() {
|
||||||
|
actionItemIds = Array.from(selectedItems);
|
||||||
|
actionItemName = null;
|
||||||
|
showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelection(id: string) {
|
||||||
|
if (selectedItems.has(id)) {
|
||||||
|
selectedItems.delete(id);
|
||||||
|
} else {
|
||||||
|
selectedItems.add(id);
|
||||||
|
}
|
||||||
|
selectedItems = selectedItems; // trigger update
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(e: Event) {
|
||||||
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
|
if (checked) {
|
||||||
|
selectedItems = new Set(items.map((i) => i.id));
|
||||||
|
} else {
|
||||||
|
selectedItems = new Set();
|
||||||
|
}
|
||||||
|
selectedItems = selectedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormattedValue(key: string, value: any) {
|
||||||
|
if (propertyConfig[key]) {
|
||||||
|
return formatValue(value, propertyConfig[key]);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOtherProperties(item: any) {
|
||||||
|
const defaultProps = new Set(category.defaultProperties);
|
||||||
|
return Object.entries(item.properties || {}).filter(([key]) => !defaultProps.has(key));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Header & Breadcrumbs -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-zinc-500">
|
||||||
|
<a href="/categories" class="transition-colors hover:text-white">Categories</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span class="font-medium text-white">{category.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if selectedItems.size > 0}
|
||||||
|
<div
|
||||||
|
class="animate-in fade-in slide-in-from-top-2 mr-4 flex items-center gap-2 duration-200"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-zinc-400">{selectedItems.size} selected</span>
|
||||||
|
<button
|
||||||
|
on:click={openBulkMoveModal}
|
||||||
|
class="flex items-center gap-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-white"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Move
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={openBulkDeleteModal}
|
||||||
|
class="flex items-center gap-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:border-red-900/30 hover:bg-red-900/20 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
on:click={openCreateItemModal}
|
||||||
|
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-red-900/20 transition-colors hover:bg-red-700"
|
||||||
|
>
|
||||||
|
+ New Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item Modal -->
|
||||||
|
<ItemModal
|
||||||
|
bind:show={showItemModal}
|
||||||
|
item={editingItem}
|
||||||
|
{categories}
|
||||||
|
{folders}
|
||||||
|
currentFolderId={null}
|
||||||
|
currentCategoryId={category.id}
|
||||||
|
user={data.user}
|
||||||
|
onClose={() => (showItemModal = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Move Item Modal -->
|
||||||
|
<MoveItemModal
|
||||||
|
bind:show={showMoveItemModal}
|
||||||
|
itemIds={actionItemIds}
|
||||||
|
itemName={actionItemName}
|
||||||
|
{folders}
|
||||||
|
onClose={() => (showMoveItemModal = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Delete Confirm Modal -->
|
||||||
|
<DeleteConfirmModal
|
||||||
|
bind:show={showDeleteModal}
|
||||||
|
itemIds={actionItemIds}
|
||||||
|
itemName={actionItemName}
|
||||||
|
onClose={() => (showDeleteModal = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Items List -->
|
||||||
|
{#if items.length > 0}
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-3 text-xs font-semibold tracking-wider text-zinc-500 uppercase">
|
||||||
|
Items in {category.name}
|
||||||
|
</h2>
|
||||||
|
<div class="overflow-hidden rounded-md border border-zinc-700 bg-zinc-900/30">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-zinc-700 bg-zinc-900/50">
|
||||||
|
<tr>
|
||||||
|
<th class="w-12 px-4 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={isIndeterminate}
|
||||||
|
on:change={toggleAll}
|
||||||
|
class="checked:bg-[url('data:image/svg+xml,%3csvg viewBox=\'0 0 16 16\' fill=\'white\' xmlns=\'http://www.w3.org/2000/svg\'%3e%3cpath d=\'M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z\'/%3e%3c/svg%3e')] h-4 w-4 shrink-0 cursor-pointer appearance-none rounded border border-zinc-700 bg-zinc-900 bg-center bg-no-repeat transition-colors checked:border-zinc-700 checked:bg-zinc-900 focus:ring-1 focus:ring-red-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Name</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Location</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Count</th>
|
||||||
|
|
||||||
|
<!-- Dynamic Headers for Default Properties -->
|
||||||
|
{#each category.defaultProperties as prop}
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500 capitalize">{prop}</th>
|
||||||
|
{/each}
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Other Properties</th>
|
||||||
|
<th class="px-4 py-2 text-right font-medium text-zinc-500">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-zinc-800/50">
|
||||||
|
{#each items as item}
|
||||||
|
<tr
|
||||||
|
class="group transition-colors hover:bg-zinc-800/30 {selectedItems.has(item.id)
|
||||||
|
? 'bg-red-900/5'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedItems.has(item.id)}
|
||||||
|
on:change={() => toggleSelection(item.id)}
|
||||||
|
class="checked:bg-[url('data:image/svg+xml,%3csvg viewBox=\'0 0 16 16\' fill=\'white\' xmlns=\'http://www.w3.org/2000/svg\'%3e%3cpath d=\'M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z\'/%3e%3c/svg%3e')] h-4 w-4 shrink-0 cursor-pointer appearance-none rounded border border-zinc-700 bg-zinc-900 bg-center bg-no-repeat transition-colors checked:border-zinc-700 checked:bg-zinc-900 focus:ring-1 focus:ring-red-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 font-medium text-zinc-200">
|
||||||
|
<button
|
||||||
|
class="text-left hover:text-red-400 hover:underline"
|
||||||
|
on:click={() => openEditItemModal(item)}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-zinc-500">
|
||||||
|
{#if item.folderPath}
|
||||||
|
<a
|
||||||
|
href="/folders/{item.folderPath}"
|
||||||
|
class="transition-colors hover:text-white hover:underline"
|
||||||
|
>
|
||||||
|
{item.folderName}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
Unassigned
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 font-mono text-zinc-400">
|
||||||
|
<div class="group/count flex items-center gap-3">
|
||||||
|
<span>
|
||||||
|
{item.count}
|
||||||
|
{#if item.reservedCount > 0}
|
||||||
|
<span class="text-red-500"> - {item.reservedCount}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="flex items-center rounded-md border border-zinc-700 bg-zinc-800 opacity-0 shadow-sm transition-opacity group-hover/count:opacity-100"
|
||||||
|
>
|
||||||
|
<form method="POST" action="?/updateCount" use:enhance class="flex">
|
||||||
|
<input type="hidden" name="id" value={item.id} />
|
||||||
|
<input type="hidden" name="delta" value="-1" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-l-md border-r border-zinc-700 p-1 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-white"
|
||||||
|
title="Decrement"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-3 w-3"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="?/updateCount" use:enhance class="flex">
|
||||||
|
<input type="hidden" name="id" value={item.id} />
|
||||||
|
<input type="hidden" name="delta" value="1" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-r-md p-1 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-white"
|
||||||
|
title="Increment"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-3 w-3"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Dynamic Values for Default Properties -->
|
||||||
|
{#each category.defaultProperties as prop}
|
||||||
|
<td class="px-4 py-2 text-zinc-400">
|
||||||
|
{#if prop === 'Datasheet' || prop === 'Link'}
|
||||||
|
{#if item.properties[prop]}
|
||||||
|
<a
|
||||||
|
href={item.properties[prop]}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-red-400 hover:underline">Link</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
-
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{item.properties[prop] ? getFormattedValue(prop, item.properties[prop]) : '-'}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
<td class="px-4 py-2 text-xs text-zinc-500">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
{#each Object.entries(item.properties) as [key, value]}
|
||||||
|
{#if !category.defaultProperties.includes(key)}
|
||||||
|
<span
|
||||||
|
><span class="font-medium text-zinc-400">{key}:</span>
|
||||||
|
{#if key === 'Datasheet' || key === 'Link'}
|
||||||
|
<a
|
||||||
|
href={value}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-red-400 hover:underline">Link</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{getFormattedValue(key, value)}
|
||||||
|
{/if}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="px-4 py-2 text-right opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
on:click={() => openMoveItemModal(item)}
|
||||||
|
class="text-zinc-600 transition-colors hover:text-white"
|
||||||
|
title="Move"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => openEditItemModal(item)}
|
||||||
|
class="text-zinc-600 transition-colors hover:text-white"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => openDeleteModal(item)}
|
||||||
|
class="text-zinc-600 transition-colors hover:text-red-500"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-md border border-dashed border-zinc-800 bg-zinc-900/20 py-12 text-center">
|
||||||
|
<div class="mb-2 text-zinc-600">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="mx-auto h-12 w-12"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-zinc-300">No Items</h3>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">This category has no items yet.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
141
src/routes/folders/+page.server.ts
Normal file
141
src/routes/folders/+page.server.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { items, folders, categories, reservations } from '$lib/server/db/schema';
|
||||||
|
import { eq, and, isNull, inArray, ilike, sql } from 'drizzle-orm';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { processProperties } from '$lib/utils/propertyUtils';
|
||||||
|
|
||||||
|
export const load = async ({ url }) => {
|
||||||
|
const currentFolder = null;
|
||||||
|
const breadcrumbs: any[] = [];
|
||||||
|
const searchQuery = url.searchParams.get('search');
|
||||||
|
|
||||||
|
// Fetch subfolders (root)
|
||||||
|
const subfolders = await db.select().from(folders)
|
||||||
|
.where(isNull(folders.parentId));
|
||||||
|
|
||||||
|
let folderItemsQuery = db.select({
|
||||||
|
id: items.id,
|
||||||
|
name: items.name,
|
||||||
|
count: items.count,
|
||||||
|
categoryName: categories.name,
|
||||||
|
categoryId: items.categoryId,
|
||||||
|
folderId: items.folderId,
|
||||||
|
tags: items.tags,
|
||||||
|
properties: items.properties,
|
||||||
|
reservedCount: sql<number>`(SELECT COALESCE(SUM(count), 0) FROM reservations WHERE item_id = ${items.id})`.mapWith(Number)
|
||||||
|
})
|
||||||
|
.from(items)
|
||||||
|
.leftJoin(categories, eq(items.categoryId, categories.id));
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
folderItemsQuery = folderItemsQuery.where(ilike(items.name, `%${searchQuery}%`));
|
||||||
|
} else {
|
||||||
|
folderItemsQuery = folderItemsQuery.where(isNull(items.folderId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderItems = await folderItemsQuery;
|
||||||
|
|
||||||
|
const allCategories = await db.select().from(categories);
|
||||||
|
const allFolders = await db.select().from(folders);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentFolder,
|
||||||
|
breadcrumbs,
|
||||||
|
items: folderItems,
|
||||||
|
subfolders: searchQuery ? [] : subfolders, // Hide folders when searching
|
||||||
|
categories: allCategories,
|
||||||
|
allFolders
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
createFolder: async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const name = data.get('name') as string;
|
||||||
|
const parentId = data.get('parentId') as string || null;
|
||||||
|
|
||||||
|
await db.insert(folders).values({
|
||||||
|
name,
|
||||||
|
parentId
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
createItem: async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const name = data.get('name') as string;
|
||||||
|
const folderId = data.get('folderId') as string;
|
||||||
|
const categoryId = data.get('categoryId') as string;
|
||||||
|
const count = parseInt(data.get('count') as string) || 0;
|
||||||
|
|
||||||
|
const properties = processProperties(data);
|
||||||
|
|
||||||
|
await db.insert(items).values({
|
||||||
|
name,
|
||||||
|
folderId: folderId || null,
|
||||||
|
categoryId,
|
||||||
|
count,
|
||||||
|
properties
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
updateItem: async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
const name = data.get('name') as string;
|
||||||
|
const categoryId = data.get('categoryId') as string;
|
||||||
|
const count = parseInt(data.get('count') as string) || 0;
|
||||||
|
const folderId = data.get('folderId') as string;
|
||||||
|
|
||||||
|
const properties = processProperties(data);
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
name,
|
||||||
|
categoryId,
|
||||||
|
count,
|
||||||
|
properties,
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (folderId !== undefined) {
|
||||||
|
updateData.folderId = folderId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(items)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(items.id, id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
deleteItem: async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
await db.delete(items).where(eq(items.id, id));
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
moveItem: async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
const folderId = data.get('folderId') as string;
|
||||||
|
await db.update(items).set({ folderId: folderId || null }).where(eq(items.id, id));
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
bulkDelete: async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const ids = JSON.parse(data.get('ids') as string);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await db.delete(items).where(inArray(items.id, ids));
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
bulkMove: async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const ids = JSON.parse(data.get('ids') as string);
|
||||||
|
const folderId = data.get('folderId') as string;
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await db.update(items).set({ folderId: folderId || null }).where(inArray(items.id, ids));
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
459
src/routes/folders/+page.svelte
Normal file
459
src/routes/folders/+page.svelte
Normal file
@ -0,0 +1,459 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import ItemModal from '$lib/components/ItemModal.svelte';
|
||||||
|
import MoveItemModal from '$lib/components/MoveItemModal.svelte';
|
||||||
|
import DeleteConfirmModal from '$lib/components/DeleteConfirmModal.svelte';
|
||||||
|
|
||||||
|
import { propertyConfig } from '$lib/config/properties';
|
||||||
|
import { formatValue } from '$lib/utils/units';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
$: ({ currentFolder, breadcrumbs, subfolders, items, categories } = data);
|
||||||
|
|
||||||
|
let folderDialog: HTMLDialogElement;
|
||||||
|
|
||||||
|
// Item Modal State
|
||||||
|
let showItemModal = false;
|
||||||
|
let showMoveItemModal = false;
|
||||||
|
let showDeleteModal = false;
|
||||||
|
|
||||||
|
let editingItem: any = null;
|
||||||
|
|
||||||
|
// Bulk/Single Action State
|
||||||
|
let selectedItems = new Set<string>();
|
||||||
|
let actionItemIds: string[] = [];
|
||||||
|
let actionItemName: string | null = null; // For display in single item actions
|
||||||
|
|
||||||
|
// Reactive statement to handle selection updates
|
||||||
|
$: allSelected = items.length > 0 && selectedItems.size === items.length;
|
||||||
|
$: isIndeterminate = selectedItems.size > 0 && selectedItems.size < items.length;
|
||||||
|
|
||||||
|
function openFolderModal() {
|
||||||
|
folderDialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFolderModal() {
|
||||||
|
folderDialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateItemModal() {
|
||||||
|
editingItem = null;
|
||||||
|
showItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditItemModal(item: any) {
|
||||||
|
editingItem = item;
|
||||||
|
showItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoveItemModal(item: any) {
|
||||||
|
actionItemIds = [item.id];
|
||||||
|
actionItemName = item.name;
|
||||||
|
showMoveItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBulkMoveModal() {
|
||||||
|
actionItemIds = Array.from(selectedItems);
|
||||||
|
actionItemName = null;
|
||||||
|
showMoveItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal(item: any) {
|
||||||
|
actionItemIds = [item.id];
|
||||||
|
actionItemName = item.name;
|
||||||
|
showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBulkDeleteModal() {
|
||||||
|
actionItemIds = Array.from(selectedItems);
|
||||||
|
actionItemName = null;
|
||||||
|
showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelection(id: string) {
|
||||||
|
if (selectedItems.has(id)) {
|
||||||
|
selectedItems.delete(id);
|
||||||
|
} else {
|
||||||
|
selectedItems.add(id);
|
||||||
|
}
|
||||||
|
selectedItems = selectedItems; // trigger update
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(e: Event) {
|
||||||
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
|
if (checked) {
|
||||||
|
selectedItems = new Set(items.map((i) => i.id));
|
||||||
|
} else {
|
||||||
|
selectedItems = new Set();
|
||||||
|
}
|
||||||
|
selectedItems = selectedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormattedValue(key: string, value: any) {
|
||||||
|
if (propertyConfig[key]) {
|
||||||
|
return formatValue(value, propertyConfig[key]);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Header & Breadcrumbs -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-zinc-500">
|
||||||
|
<a href="/folders" class="transition-colors hover:text-white">Home</a>
|
||||||
|
{#each breadcrumbs as crumb}
|
||||||
|
<span>/</span>
|
||||||
|
<a
|
||||||
|
href="/folders{crumb.url.startsWith('/') ? '' : '/'}{crumb.url}"
|
||||||
|
class="font-medium text-zinc-300 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
{crumb.name}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if selectedItems.size > 0}
|
||||||
|
<div
|
||||||
|
class="animate-in fade-in slide-in-from-top-2 mr-4 flex items-center gap-2 duration-200"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-zinc-400">{selectedItems.size} selected</span>
|
||||||
|
<button
|
||||||
|
on:click={openBulkMoveModal}
|
||||||
|
class="flex items-center gap-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-white"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Move
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={openBulkDeleteModal}
|
||||||
|
class="flex items-center gap-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:border-red-900/30 hover:bg-red-900/20 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
on:click={openFolderModal}
|
||||||
|
class="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-white"
|
||||||
|
>
|
||||||
|
+ New Location
|
||||||
|
</button>
|
||||||
|
{#if currentFolder}
|
||||||
|
<button
|
||||||
|
on:click={openCreateItemModal}
|
||||||
|
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-red-900/20 transition-colors hover:bg-red-700"
|
||||||
|
>
|
||||||
|
+ New Item
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Folder Modal -->
|
||||||
|
<dialog
|
||||||
|
bind:this={folderDialog}
|
||||||
|
class="m-auto w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-950 p-0 text-white shadow-2xl backdrop:bg-black/80"
|
||||||
|
>
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="mb-4 text-lg font-bold text-white">Create New Location</h2>
|
||||||
|
<form method="POST" action="?/createFolder" class="space-y-4" on:submit={closeFolderModal}>
|
||||||
|
<input type="hidden" name="parentId" value={currentFolder?.id || ''} />
|
||||||
|
<div>
|
||||||
|
<label for="folderName" class="mb-1 block text-sm font-medium text-zinc-400"
|
||||||
|
>Location Name</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
id="folderName"
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white placeholder-zinc-600 transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
|
||||||
|
placeholder="e.g. Components"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={closeFolderModal}
|
||||||
|
class="rounded-md px-4 py-2 font-medium text-zinc-400 transition-colors hover:bg-zinc-800"
|
||||||
|
>Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-red-600 px-4 py-2 font-medium text-white shadow-lg shadow-red-900/20 transition-colors hover:bg-red-700"
|
||||||
|
>Create Location</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Item Modal -->
|
||||||
|
<ItemModal
|
||||||
|
bind:show={showItemModal}
|
||||||
|
item={editingItem}
|
||||||
|
{categories}
|
||||||
|
folders={data.allFolders || []}
|
||||||
|
currentFolderId={currentFolder?.id}
|
||||||
|
onClose={() => (showItemModal = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Move Item Modal -->
|
||||||
|
<MoveItemModal
|
||||||
|
bind:show={showMoveItemModal}
|
||||||
|
itemIds={actionItemIds}
|
||||||
|
itemName={actionItemName}
|
||||||
|
folders={data.allFolders || []}
|
||||||
|
onClose={() => (showMoveItemModal = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Delete Confirm Modal -->
|
||||||
|
<DeleteConfirmModal
|
||||||
|
bind:show={showDeleteModal}
|
||||||
|
itemIds={actionItemIds}
|
||||||
|
itemName={actionItemName}
|
||||||
|
onClose={() => (showDeleteModal = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Folders Grid -->
|
||||||
|
{#if subfolders.length > 0}
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-3 text-xs font-semibold tracking-wider text-zinc-500 uppercase">Locations</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{#each subfolders as folder}
|
||||||
|
<a
|
||||||
|
href="{$page.url.pathname === '/folders'
|
||||||
|
? '/folders'
|
||||||
|
: $page.url.pathname}/{folder.name}"
|
||||||
|
class="group flex items-center gap-3 rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all duration-200 hover:border-red-500/50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="rounded bg-zinc-800 p-2 text-zinc-400 transition-colors group-hover:text-white"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="truncate font-medium text-zinc-300 group-hover:text-white"
|
||||||
|
>{folder.name}</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Items List -->
|
||||||
|
{#if items.length > 0}
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-3 text-xs font-semibold tracking-wider text-zinc-500 uppercase">Items</h2>
|
||||||
|
<div class="overflow-hidden rounded-md border border-zinc-700 bg-zinc-900/30">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-zinc-700 bg-zinc-900/50">
|
||||||
|
<tr>
|
||||||
|
<th class="w-12 px-4 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={isIndeterminate}
|
||||||
|
on:change={toggleAll}
|
||||||
|
class="checked:bg-[url('data:image/svg+xml,%3csvg viewBox=\'0 0 16 16\' fill=\'white\' xmlns=\'http://www.w3.org/2000/svg\'%3e%3cpath d=\'M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z\'/%3e%3c/svg%3e')] h-4 w-4 shrink-0 cursor-pointer appearance-none rounded border border-zinc-700 bg-zinc-900 bg-center bg-no-repeat transition-colors checked:border-zinc-700 checked:bg-zinc-900 focus:ring-1 focus:ring-red-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Name</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Category</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Count</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Tags</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Properties</th>
|
||||||
|
<th class="px-4 py-2 text-right font-medium text-zinc-500">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-zinc-800/50">
|
||||||
|
{#each items as item}
|
||||||
|
<tr
|
||||||
|
class="group transition-colors hover:bg-zinc-800/30 {selectedItems.has(item.id)
|
||||||
|
? 'bg-red-900/5'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedItems.has(item.id)}
|
||||||
|
on:change={() => toggleSelection(item.id)}
|
||||||
|
class="checked:bg-[url('data:image/svg+xml,%3csvg viewBox=\'0 0 16 16\' fill=\'white\' xmlns=\'http://www.w3.org/2000/svg\'%3e%3cpath d=\'M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z\'/%3e%3c/svg%3e')] h-4 w-4 shrink-0 cursor-pointer appearance-none rounded border border-zinc-700 bg-zinc-900 bg-center bg-no-repeat transition-colors checked:border-zinc-700 checked:bg-zinc-900 focus:ring-1 focus:ring-red-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 font-medium text-zinc-200">{item.name}</td>
|
||||||
|
<td class="px-4 py-2 text-zinc-500">
|
||||||
|
<span
|
||||||
|
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-0.5 text-xs font-medium text-zinc-300"
|
||||||
|
>
|
||||||
|
{item.categoryName}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 font-mono text-zinc-400">
|
||||||
|
{item.count}
|
||||||
|
{#if item.reservedCount > 0}
|
||||||
|
<span class="text-red-500"> - {item.reservedCount}</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each item.tags as tag}
|
||||||
|
<span
|
||||||
|
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-0.5 text-xs text-zinc-400"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-zinc-500">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
{#each Object.entries(item.properties) as [key, value]}
|
||||||
|
<span
|
||||||
|
><span class="font-medium text-zinc-400">{key}:</span>
|
||||||
|
{getFormattedValue(key, value)}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="px-4 py-2 text-right opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
on:click={() => openMoveItemModal(item)}
|
||||||
|
class="text-zinc-600 transition-colors hover:text-white"
|
||||||
|
title="Move"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => openEditItemModal(item)}
|
||||||
|
class="text-zinc-600 transition-colors hover:text-white"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => openDeleteModal(item)}
|
||||||
|
class="text-zinc-600 transition-colors hover:text-red-500"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if currentFolder && subfolders.length === 0}
|
||||||
|
<div class="rounded-md border border-dashed border-zinc-800 bg-zinc-900/20 py-12 text-center">
|
||||||
|
<div class="mb-2 text-zinc-600">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="mx-auto h-12 w-12"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-zinc-300">Empty Location</h3>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
This location is empty. Add items or sublocations to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
209
src/routes/folders/[...path]/+page.server.ts
Normal file
209
src/routes/folders/[...path]/+page.server.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { items, folders, categories, reservations } from '$lib/server/db/schema';
|
||||||
|
import { eq, and, isNull, inArray, sql } from 'drizzle-orm';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { processProperties } from '$lib/utils/propertyUtils';
|
||||||
|
|
||||||
|
export const load = async ({ params, url }: { params: { path: string }, url: URL }) => {
|
||||||
|
const pathSegments = params.path.split('/').filter(Boolean);
|
||||||
|
let currentFolder = null;
|
||||||
|
const breadcrumbs: any[] = [];
|
||||||
|
let parentId = null;
|
||||||
|
|
||||||
|
for (const segment of pathSegments) {
|
||||||
|
const folder = await db.select().from(folders)
|
||||||
|
.where(and(
|
||||||
|
eq(folders.name, decodeURIComponent(segment)),
|
||||||
|
parentId ? eq(folders.parentId, parentId) : isNull(folders.parentId)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (folder.length === 0) {
|
||||||
|
throw error(404, 'Folder not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFolder = folder[0];
|
||||||
|
parentId = currentFolder.id;
|
||||||
|
const parentPath = breadcrumbs.map(b => b.name).join('/');
|
||||||
|
breadcrumbs.push({
|
||||||
|
name: currentFolder.name,
|
||||||
|
url: `/folders${parentPath ? '/' + parentPath : ''}/${currentFolder.name}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch subfolders
|
||||||
|
const subfolders = await db.select().from(folders)
|
||||||
|
.where(currentFolder ? eq(folders.parentId, currentFolder.id) : isNull(folders.parentId));
|
||||||
|
|
||||||
|
// Fetch items
|
||||||
|
const folderItems = await db.select({
|
||||||
|
id: items.id,
|
||||||
|
name: items.name,
|
||||||
|
count: items.count,
|
||||||
|
categoryName: categories.name,
|
||||||
|
categoryId: items.categoryId,
|
||||||
|
folderId: items.folderId,
|
||||||
|
tags: items.tags,
|
||||||
|
properties: items.properties,
|
||||||
|
reservedCount: sql<number>`(SELECT COALESCE(SUM(count), 0) FROM reservations WHERE item_id = ${items.id})`.mapWith(Number)
|
||||||
|
})
|
||||||
|
.from(items)
|
||||||
|
.leftJoin(categories, eq(items.categoryId, categories.id))
|
||||||
|
.where(currentFolder ? eq(items.folderId, currentFolder.id) : isNull(items.folderId));
|
||||||
|
|
||||||
|
const allCategories = await db.select().from(categories);
|
||||||
|
const allFolders = await db.select().from(folders);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentFolder,
|
||||||
|
breadcrumbs,
|
||||||
|
items: folderItems,
|
||||||
|
subfolders,
|
||||||
|
categories: allCategories,
|
||||||
|
allFolders
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
createFolder: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const name = data.get('name') as string;
|
||||||
|
const parentId = data.get('parentId') as string || null;
|
||||||
|
|
||||||
|
await db.insert(folders).values({
|
||||||
|
name,
|
||||||
|
parentId
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
createItem: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const name = data.get('name') as string;
|
||||||
|
const folderId = data.get('folderId') as string;
|
||||||
|
const categoryId = data.get('categoryId') as string;
|
||||||
|
const count = parseInt(data.get('count') as string) || 0;
|
||||||
|
|
||||||
|
const properties = processProperties(data);
|
||||||
|
|
||||||
|
await db.insert(items).values({
|
||||||
|
name,
|
||||||
|
folderId: folderId || null,
|
||||||
|
categoryId,
|
||||||
|
count,
|
||||||
|
properties
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
updateItem: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
const name = data.get('name') as string;
|
||||||
|
const categoryId = data.get('categoryId') as string;
|
||||||
|
const count = parseInt(data.get('count') as string) || 0;
|
||||||
|
const folderId = data.get('folderId') as string;
|
||||||
|
|
||||||
|
const properties = processProperties(data);
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
name,
|
||||||
|
categoryId,
|
||||||
|
count,
|
||||||
|
properties,
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (folderId !== undefined) {
|
||||||
|
updateData.folderId = folderId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(items)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(items.id, id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
deleteItem: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
await db.delete(items).where(eq(items.id, id));
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
moveItem: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
const folderId = data.get('folderId') as string;
|
||||||
|
await db.update(items).set({ folderId: folderId || null }).where(eq(items.id, id));
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
bulkDelete: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const ids = JSON.parse(data.get('ids') as string);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await db.delete(items).where(inArray(items.id, ids));
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
bulkMove: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const ids = JSON.parse(data.get('ids') as string);
|
||||||
|
const folderId = data.get('folderId') as string;
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await db.update(items).set({ folderId: folderId || null }).where(inArray(items.id, ids));
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
updateCount: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
const delta = parseInt(data.get('delta') as string) || 0;
|
||||||
|
|
||||||
|
const item = await db.select({ count: items.count }).from(items).where(eq(items.id, id)).limit(1);
|
||||||
|
|
||||||
|
if (item.length > 0) {
|
||||||
|
const newCount = Math.max(0, (item[0].count || 0) + delta);
|
||||||
|
await db.update(items).set({ count: newCount }).where(eq(items.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
renameFolder: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
const name = data.get('name') as string;
|
||||||
|
|
||||||
|
await db.update(folders)
|
||||||
|
.set({ name })
|
||||||
|
.where(eq(folders.id, id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
moveFolder: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
const parentId = data.get('folderId') as string; // Reusing folderId param name from item move
|
||||||
|
|
||||||
|
// Prevent moving folder into itself or its children (basic check: not into itself)
|
||||||
|
if (id === parentId) {
|
||||||
|
// In a real app we'd check for cycles, but for now just prevent direct self-parenting
|
||||||
|
return { success: false, error: "Cannot move folder into itself" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(folders)
|
||||||
|
.set({ parentId: parentId || null })
|
||||||
|
.where(eq(folders.id, id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
deleteFolder: async ({ request }: { request: Request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id') as string;
|
||||||
|
|
||||||
|
// Note: This assumes cascading delete or that the user has emptied the folder.
|
||||||
|
// If not cascading, this might fail. For now, we assume simple delete.
|
||||||
|
await db.delete(folders).where(eq(folders.id, id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
580
src/routes/folders/[...path]/+page.svelte
Normal file
580
src/routes/folders/[...path]/+page.svelte
Normal file
@ -0,0 +1,580 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import ItemModal from '$lib/components/ItemModal.svelte';
|
||||||
|
import MoveItemModal from '$lib/components/MoveItemModal.svelte';
|
||||||
|
import DeleteConfirmModal from '$lib/components/DeleteConfirmModal.svelte';
|
||||||
|
import FolderModal from '$lib/components/FolderModal.svelte';
|
||||||
|
|
||||||
|
import { propertyConfig } from '$lib/config/properties';
|
||||||
|
import { formatValue } from '$lib/utils/units';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
$: ({ currentFolder, breadcrumbs, subfolders, items, categories } = data);
|
||||||
|
|
||||||
|
// Item Modal State
|
||||||
|
let showItemModal = false;
|
||||||
|
let showFolderModal = false;
|
||||||
|
let showMoveItemModal = false;
|
||||||
|
let showDeleteModal = false;
|
||||||
|
|
||||||
|
let editingItem: any = null;
|
||||||
|
let editingFolder: any = null;
|
||||||
|
|
||||||
|
// Bulk/Single Action State
|
||||||
|
let selectedItems = new Set<string>();
|
||||||
|
let actionItemIds: string[] = [];
|
||||||
|
let actionItemName: string | null = null; // For display in single item actions
|
||||||
|
let actionType: 'item' | 'folder' = 'item';
|
||||||
|
|
||||||
|
// Reactive statement to handle selection updates
|
||||||
|
$: allSelected = items.length > 0 && selectedItems.size === items.length;
|
||||||
|
$: isIndeterminate = selectedItems.size > 0 && selectedItems.size < items.length;
|
||||||
|
|
||||||
|
function openCreateFolderModal() {
|
||||||
|
editingFolder = null;
|
||||||
|
showFolderModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRenameFolderModal(folder: any) {
|
||||||
|
editingFolder = folder;
|
||||||
|
showFolderModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateItemModal() {
|
||||||
|
editingItem = null;
|
||||||
|
showItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditItemModal(item: any) {
|
||||||
|
editingItem = item;
|
||||||
|
showItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoveItemModal(item: any) {
|
||||||
|
actionItemIds = [item.id];
|
||||||
|
actionItemName = item.name;
|
||||||
|
actionType = 'item';
|
||||||
|
showMoveItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoveFolderModal(folder: any) {
|
||||||
|
actionItemIds = [folder.id];
|
||||||
|
actionItemName = folder.name;
|
||||||
|
actionType = 'folder';
|
||||||
|
showMoveItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBulkMoveModal() {
|
||||||
|
actionItemIds = Array.from(selectedItems);
|
||||||
|
actionItemName = null;
|
||||||
|
actionType = 'item';
|
||||||
|
showMoveItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal(item: any) {
|
||||||
|
actionItemIds = [item.id];
|
||||||
|
actionItemName = item.name;
|
||||||
|
actionType = 'item';
|
||||||
|
showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteFolderModal(folder: any) {
|
||||||
|
actionItemIds = [folder.id];
|
||||||
|
actionItemName = folder.name;
|
||||||
|
actionType = 'folder';
|
||||||
|
showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBulkDeleteModal() {
|
||||||
|
actionItemIds = Array.from(selectedItems);
|
||||||
|
actionItemName = null;
|
||||||
|
actionType = 'item';
|
||||||
|
showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelection(id: string) {
|
||||||
|
if (selectedItems.has(id)) {
|
||||||
|
selectedItems.delete(id);
|
||||||
|
} else {
|
||||||
|
selectedItems.add(id);
|
||||||
|
}
|
||||||
|
selectedItems = selectedItems; // trigger update
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(e: Event) {
|
||||||
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
|
if (checked) {
|
||||||
|
selectedItems = new Set(items.map((i) => i.id));
|
||||||
|
} else {
|
||||||
|
selectedItems = new Set();
|
||||||
|
}
|
||||||
|
selectedItems = selectedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormattedValue(key: string, value: any) {
|
||||||
|
if (propertyConfig[key]) {
|
||||||
|
return formatValue(value, propertyConfig[key]);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Header & Breadcrumbs -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-zinc-500">
|
||||||
|
<a href="/folders" class="transition-colors hover:text-white">Home</a>
|
||||||
|
{#each breadcrumbs as crumb}
|
||||||
|
<span>/</span>
|
||||||
|
<a href={crumb.url} class="font-medium text-zinc-300 transition-colors hover:text-white">
|
||||||
|
{crumb.name}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if selectedItems.size > 0}
|
||||||
|
<div
|
||||||
|
class="animate-in fade-in slide-in-from-top-2 mr-4 flex items-center gap-2 duration-200"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-zinc-400">{selectedItems.size} selected</span>
|
||||||
|
<button
|
||||||
|
on:click={openBulkMoveModal}
|
||||||
|
class="flex items-center gap-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-white"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Move
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={openBulkDeleteModal}
|
||||||
|
class="flex items-center gap-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:border-red-900/30 hover:bg-red-900/20 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
on:click={openCreateFolderModal}
|
||||||
|
class="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-white"
|
||||||
|
>
|
||||||
|
+ New Folder
|
||||||
|
</button>
|
||||||
|
{#if currentFolder}
|
||||||
|
<button
|
||||||
|
on:click={openCreateItemModal}
|
||||||
|
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-red-900/20 transition-colors hover:bg-red-700"
|
||||||
|
>
|
||||||
|
+ New Item
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Folder Modal -->
|
||||||
|
<FolderModal
|
||||||
|
bind:show={showFolderModal}
|
||||||
|
folder={editingFolder}
|
||||||
|
parentId={currentFolder?.id}
|
||||||
|
onClose={() => (showFolderModal = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Item Modal -->
|
||||||
|
<ItemModal
|
||||||
|
bind:show={showItemModal}
|
||||||
|
item={editingItem}
|
||||||
|
{categories}
|
||||||
|
folders={data.allFolders || []}
|
||||||
|
currentFolderId={currentFolder?.id}
|
||||||
|
user={data.user}
|
||||||
|
onClose={() => (showItemModal = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Move Item/Folder Modal -->
|
||||||
|
<MoveItemModal
|
||||||
|
bind:show={showMoveItemModal}
|
||||||
|
itemIds={actionItemIds}
|
||||||
|
itemName={actionItemName}
|
||||||
|
folders={data.allFolders || []}
|
||||||
|
type={actionType}
|
||||||
|
onClose={() => (showMoveItemModal = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Delete Confirm Modal -->
|
||||||
|
<DeleteConfirmModal
|
||||||
|
bind:show={showDeleteModal}
|
||||||
|
itemIds={actionItemIds}
|
||||||
|
itemName={actionItemName}
|
||||||
|
type={actionType}
|
||||||
|
onClose={() => (showDeleteModal = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Folders Grid -->
|
||||||
|
{#if subfolders.length > 0}
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-3 text-xs font-semibold tracking-wider text-zinc-500 uppercase">Folders</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{#each subfolders as folder}
|
||||||
|
<div
|
||||||
|
class="group relative flex items-center gap-3 rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all duration-200 hover:border-red-500/50"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{$page.url.pathname === '/folders'
|
||||||
|
? '/folders'
|
||||||
|
: $page.url.pathname}/{folder.name}"
|
||||||
|
class="absolute inset-0 z-0"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Open {folder.name}</span>
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none z-10 rounded bg-zinc-800 p-2 text-zinc-400 transition-colors group-hover:text-white"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none z-10 truncate font-medium text-zinc-300 group-hover:text-white"
|
||||||
|
>{folder.name}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Folder Actions -->
|
||||||
|
<div
|
||||||
|
class="absolute top-1/2 right-2 z-20 hidden -translate-y-1/2 gap-1 group-hover:flex"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
on:click|preventDefault={() => openRenameFolderModal(folder)}
|
||||||
|
class="rounded p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-white"
|
||||||
|
title="Rename"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click|preventDefault={() => openMoveFolderModal(folder)}
|
||||||
|
class="rounded p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-white"
|
||||||
|
title="Move"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click|preventDefault={() => openDeleteFolderModal(folder)}
|
||||||
|
class="rounded p-1.5 text-zinc-400 hover:bg-red-900/30 hover:text-red-500"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Items List -->
|
||||||
|
{#if items.length > 0}
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-3 text-xs font-semibold tracking-wider text-zinc-500 uppercase">Items</h2>
|
||||||
|
<div class="overflow-hidden rounded-md border border-zinc-700 bg-zinc-900/30">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-zinc-700 bg-zinc-900/50">
|
||||||
|
<tr>
|
||||||
|
<th class="w-12 px-4 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={isIndeterminate}
|
||||||
|
on:change={toggleAll}
|
||||||
|
class="checked:bg-[url('data:image/svg+xml,%3csvg viewBox=\'0 0 16 16\' fill=\'white\' xmlns=\'http://www.w3.org/2000/svg\'%3e%3cpath d=\'M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z\'/%3e%3c/svg%3e')] h-4 w-4 shrink-0 cursor-pointer appearance-none rounded border border-zinc-700 bg-zinc-900 bg-center bg-no-repeat transition-colors checked:border-zinc-700 checked:bg-zinc-900 focus:ring-1 focus:ring-red-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Name</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Category</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Count</th>
|
||||||
|
|
||||||
|
<th class="px-4 py-2 font-medium text-zinc-500">Properties</th>
|
||||||
|
<th class="px-4 py-2 text-right font-medium text-zinc-500">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-zinc-800/50">
|
||||||
|
{#each items as item}
|
||||||
|
<tr
|
||||||
|
class="group transition-colors hover:bg-zinc-800/30 {selectedItems.has(item.id)
|
||||||
|
? 'bg-red-900/5'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedItems.has(item.id)}
|
||||||
|
on:change={() => toggleSelection(item.id)}
|
||||||
|
class="checked:bg-[url('data:image/svg+xml,%3csvg viewBox=\'0 0 16 16\' fill=\'white\' xmlns=\'http://www.w3.org/2000/svg\'%3e%3cpath d=\'M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z\'/%3e%3c/svg%3e')] h-4 w-4 shrink-0 cursor-pointer appearance-none rounded border border-zinc-700 bg-zinc-900 bg-center bg-no-repeat transition-colors checked:border-zinc-700 checked:bg-zinc-900 focus:ring-1 focus:ring-red-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 font-medium text-zinc-200">
|
||||||
|
<button
|
||||||
|
class="text-left hover:text-red-400 hover:underline"
|
||||||
|
on:click={() => openEditItemModal(item)}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-zinc-500">
|
||||||
|
<span
|
||||||
|
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-0.5 text-xs font-medium text-zinc-300"
|
||||||
|
>
|
||||||
|
{item.categoryName}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 font-mono text-zinc-400">
|
||||||
|
<div class="group/count flex items-center gap-3">
|
||||||
|
<span>
|
||||||
|
{item.count}
|
||||||
|
{#if item.reservedCount > 0}
|
||||||
|
<span class="text-red-500"> - {item.reservedCount}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="flex items-center rounded-md border border-zinc-700 bg-zinc-800 opacity-0 shadow-sm transition-opacity group-hover/count:opacity-100"
|
||||||
|
>
|
||||||
|
<form method="POST" action="?/updateCount" use:enhance class="flex">
|
||||||
|
<input type="hidden" name="id" value={item.id} />
|
||||||
|
<input type="hidden" name="delta" value="-1" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-l-md border-r border-zinc-700 p-1 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-white"
|
||||||
|
title="Decrement"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-3 w-3"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="?/updateCount" use:enhance class="flex">
|
||||||
|
<input type="hidden" name="id" value={item.id} />
|
||||||
|
<input type="hidden" name="delta" value="1" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-r-md p-1 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-white"
|
||||||
|
title="Increment"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-3 w-3"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="px-4 py-2 text-xs text-zinc-500">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
{#each Object.entries(item.properties) as [key, value]}
|
||||||
|
<span
|
||||||
|
><span class="font-medium text-zinc-400">{key}:</span>
|
||||||
|
{#if key === 'Datasheet' || key === 'Link'}
|
||||||
|
<a
|
||||||
|
href={value}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-red-400 hover:underline">Link</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{getFormattedValue(key, value)}
|
||||||
|
{/if}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="px-4 py-2 text-right opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
on:click={() => openMoveItemModal(item)}
|
||||||
|
class="text-zinc-600 transition-colors hover:text-white"
|
||||||
|
title="Move"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => openEditItemModal(item)}
|
||||||
|
class="text-zinc-600 transition-colors hover:text-white"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => openDeleteModal(item)}
|
||||||
|
class="text-zinc-600 transition-colors hover:text-red-500"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if currentFolder && subfolders.length === 0}
|
||||||
|
<div class="rounded-md border border-dashed border-zinc-800 bg-zinc-900/20 py-12 text-center">
|
||||||
|
<div class="mb-2 text-zinc-600">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="mx-auto h-12 w-12"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-zinc-300">Empty Folder</h3>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
This folder is empty. Add items or subfolders to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
90
src/routes/items/+page.server.ts
Normal file
90
src/routes/items/+page.server.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { createReservation, updateReservation, deleteReservation } from '$lib/server/reservations';
|
||||||
|
import { createComment, deleteComment } from '$lib/server/comments';
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
reserve: async ({ request, locals }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const itemId = data.get('itemId')?.toString();
|
||||||
|
const count = parseInt(data.get('count')?.toString() || '0');
|
||||||
|
// Mock user ID for now if locals.user is not available, but ideally use locals.user.id
|
||||||
|
// The user prompt implies auth exists ("userId: FK to User").
|
||||||
|
// I'll assume locals.user.id exists or use a placeholder if not.
|
||||||
|
// Checking schema, sessions table exists, so auth is likely implemented.
|
||||||
|
// I will assume `locals.user` is populated by hooks.
|
||||||
|
const userId = locals.user?.id;
|
||||||
|
|
||||||
|
if (!itemId || !userId) {
|
||||||
|
return fail(400, { error: 'Missing item ID or user not logged in' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createReservation(itemId, userId, count);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return fail(500, { error: 'Failed to reserve item' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateReservation: async ({ request, locals }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id')?.toString();
|
||||||
|
const count = parseInt(data.get('count')?.toString() || '0');
|
||||||
|
const userId = locals.user?.id;
|
||||||
|
|
||||||
|
if (!id || !userId) return fail(400, { error: 'Missing ID' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// In a real app, check ownership here.
|
||||||
|
await updateReservation(id, count);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: 'Failed to update reservation' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteReservation: async ({ request, locals }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id')?.toString();
|
||||||
|
const userId = locals.user?.id;
|
||||||
|
|
||||||
|
if (!id || !userId) return fail(400, { error: 'Missing ID' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteReservation(id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: 'Failed to delete reservation' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
comment: async ({ request, locals }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const itemId = data.get('itemId')?.toString();
|
||||||
|
const text = data.get('text')?.toString();
|
||||||
|
const userId = locals.user?.id;
|
||||||
|
|
||||||
|
if (!itemId || !text || !userId) {
|
||||||
|
return fail(400, { error: 'Missing data' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createComment(itemId, userId, text);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: 'Failed to post comment' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteComment: async ({ request, locals }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const id = data.get('id')?.toString();
|
||||||
|
const userId = locals.user?.id;
|
||||||
|
|
||||||
|
if (!id || !userId) return fail(400, { error: 'Missing ID' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteComment(id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: 'Failed to delete comment' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
1
src/routes/layout.css
Normal file
1
src/routes/layout.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
17
src/routes/login/+page.svelte
Normal file
17
src/routes/login/+page.svelte
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script>
|
||||||
|
let loggingIn = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-black">
|
||||||
|
<div class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-8 shadow-md">
|
||||||
|
<h1 class="mb-6 text-center text-2xl font-bold text-white">Sign in to FT Inventory</h1>
|
||||||
|
<a
|
||||||
|
href="/login/gitea"
|
||||||
|
data-sveltekit-reload
|
||||||
|
on:click={() => (loggingIn = true)}
|
||||||
|
class="flex w-full items-center justify-center rounded-md bg-red-600 px-4 py-2 text-white transition-colors hover:bg-red-700 focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:outline-none"
|
||||||
|
>
|
||||||
|
{loggingIn ? 'Logging in...' : 'Sign in with FaSTTUBe Git'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
28
src/routes/login/gitea/+server.ts
Normal file
28
src/routes/login/gitea/+server.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { generateState, generateCodeVerifier } from 'arctic';
|
||||||
|
import { gitea } from '$lib/server/auth';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ cookies }) => {
|
||||||
|
const state = generateState();
|
||||||
|
const codeVerifier = generateCodeVerifier();
|
||||||
|
const url = await gitea.createAuthorizationURL(state, codeVerifier, ['openid', 'profile', 'email']);
|
||||||
|
|
||||||
|
cookies.set('gitea_oauth_state', state, {
|
||||||
|
path: '/',
|
||||||
|
secure: import.meta.env.PROD,
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 60 * 10,
|
||||||
|
sameSite: 'lax'
|
||||||
|
});
|
||||||
|
|
||||||
|
cookies.set('gitea_oauth_verifier', codeVerifier, {
|
||||||
|
path: '/',
|
||||||
|
secure: import.meta.env.PROD,
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 60 * 10,
|
||||||
|
sameSite: 'lax'
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(302, url.toString());
|
||||||
|
};
|
||||||
85
src/routes/login/gitea/callback/+server.ts
Normal file
85
src/routes/login/gitea/callback/+server.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { OAuth2RequestError } from 'arctic';
|
||||||
|
import { generateIdFromEntropySize } from 'lucia';
|
||||||
|
import { lucia, gitea } from '$lib/server/auth';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { users } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { GITEA_BASE_URL } from '$env/static/private';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
const storedState = cookies.get('gitea_oauth_state') ?? null;
|
||||||
|
const storedCodeVerifier = cookies.get('gitea_oauth_verifier') ?? null;
|
||||||
|
|
||||||
|
if (!code || !state || !storedState || !storedCodeVerifier || state !== storedState) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await gitea.validateAuthorizationCode(code, storedCodeVerifier);
|
||||||
|
const giteaUserResponse = await fetch(`${GITEA_BASE_URL}/login/oauth/userinfo`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens.accessToken()}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const giteaUser: GiteaUser = await giteaUserResponse.json();
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const existingUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.giteaId, giteaUser.sub)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
const session = await lucia.createSession(existingUser.id, {});
|
||||||
|
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||||
|
cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||||
|
path: '.',
|
||||||
|
...sessionCookie.attributes
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const userId = generateIdFromEntropySize(10); // 16 characters long
|
||||||
|
await db.insert(users).values({
|
||||||
|
id: userId,
|
||||||
|
giteaId: giteaUser.sub,
|
||||||
|
username: giteaUser.preferred_username,
|
||||||
|
avatarUrl: giteaUser.picture
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = await lucia.createSession(userId, {});
|
||||||
|
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||||
|
cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||||
|
path: '.',
|
||||||
|
...sessionCookie.attributes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
Location: '/'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// the specific error message depends on the provider
|
||||||
|
if (e instanceof OAuth2RequestError) {
|
||||||
|
// invalid code
|
||||||
|
return new Response(null, {
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(null, {
|
||||||
|
status: 500
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GiteaUser {
|
||||||
|
sub: number;
|
||||||
|
name: string;
|
||||||
|
preferred_username: string;
|
||||||
|
email: string;
|
||||||
|
picture: string;
|
||||||
|
}
|
||||||
16
src/routes/logout/+server.ts
Normal file
16
src/routes/logout/+server.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { lucia } from '$lib/server/auth';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ locals, cookies }) => {
|
||||||
|
if (!locals.session) {
|
||||||
|
redirect(302, '/login');
|
||||||
|
}
|
||||||
|
await lucia.invalidateSession(locals.session.id);
|
||||||
|
const sessionCookie = lucia.createBlankSessionCookie();
|
||||||
|
cookies.set(sessionCookie.name, sessionCookie.value, {
|
||||||
|
path: '.',
|
||||||
|
...sessionCookie.attributes
|
||||||
|
});
|
||||||
|
redirect(302, '/login');
|
||||||
|
};
|
||||||
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
18
svelte.config.js
Normal file
18
svelte.config.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-auto';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
// Consult https://svelte.dev/docs/kit/integrations
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
|
kit: {
|
||||||
|
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||||
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
|
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
5
todos
Normal file
5
todos
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
- Folder route
|
||||||
|
- Fix Submitting items from category view
|
||||||
|
- Editing Categories
|
||||||
|
- Add global search
|
||||||
|
- Add local search in folder & category views
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
ssr: {
|
||||||
|
noExternal: ['postgres']
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user