reservations applyable, fixed folder views, checkmarks

This commit is contained in:
Karlsson
2025-11-22 13:00:24 +01:00
parent a3a001d8e1
commit 74537558a8
15 changed files with 434 additions and 137 deletions

View File

@ -1,2 +1,8 @@
# Replace with your DB credentials!
DATABASE_URL="postgres://user:password@host:port/db-name"
# Replace with your Gitea oauth credentials!
GITEA_BASE_URL="https://gitea.example.com"
GITEA_CLIENT_ID="your-client-id"
GITEA_CLIENT_SECRET="your-client-secret"
CALLBACK_BASE_URL="http://localhost:5173"

View File

@ -168,7 +168,6 @@
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"
/>
@ -470,6 +469,34 @@
>{reservation.count} reserved</span
>
{/if}
{#if user}
<div class="ml-2">
<form
action="/items?/applyReservation"
method="POST"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') {
// Update local item count if returned
if (result.data && result.data.newCount !== undefined) {
item.count = result.data.newCount;
}
fetchDetails(false);
}
};
}}
>
<input type="hidden" name="id" value={reservation.id} />
<button
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-xs font-medium text-zinc-300 transition-colors hover:border-green-800 hover:bg-green-900/30 hover:text-green-400"
title="Apply reservation (subtract from stock and remove)"
>
Apply
</button>
</form>
</div>
{/if}
</div>
{/each}

View File

@ -29,7 +29,7 @@ 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(),
name: text('name'),
count: integer('count').default(0).notNull(),
tags: jsonb('tags').$type<string[]>().default([]).notNull(),
properties: jsonb('properties').$type<Record<string, any>>().default({}).notNull(),

View File

@ -0,0 +1,25 @@
import { db } from '$lib/server/db';
import { folders } from '$lib/server/db/schema';
import { eq, isNull, and } from 'drizzle-orm';
export async function getOrSeedHomeFolder() {
// Try to find the "Home" folder at the root level (parentId is null)
const existingHome = await db.select().from(folders)
.where(and(
eq(folders.name, 'Home'),
isNull(folders.parentId)
))
.limit(1);
if (existingHome.length > 0) {
return existingHome[0];
}
// If not found, create it
const [newHome] = await db.insert(folders).values({
name: 'Home',
parentId: null
}).returning();
return newHome;
}

View File

@ -7,12 +7,25 @@ export async function GET({ url }) {
const folderId = url.searchParams.get('folderId');
const categoryId = url.searchParams.get('categoryId');
let query = db.select().from(items);
let conditions = [];
if (folderId) {
query = query.where(eq(items.folderId, folderId));
conditions.push(eq(items.folderId, folderId));
} else if (categoryId) {
query = query.where(eq(items.categoryId, categoryId));
conditions.push(eq(items.categoryId, categoryId));
}
let query = db.select().from(items);
if (conditions.length > 0) {
// @ts-ignore - spread operator with conditions works but TS might complain about empty array if not handled, but here we check length.
// Actually, cleaner to just chain if we can, or just use `where` once.
// Let's just use the if/else to construct the query directly without reassignment if possible, or use `let query` with explicit type.
// Easiest is to just do:
if (folderId) {
return json(await db.select().from(items).where(eq(items.folderId, folderId)));
} else if (categoryId) {
return json(await db.select().from(items).where(eq(items.categoryId, categoryId)));
}
return json(await db.select().from(items));
}
const result = await query;

View File

@ -67,7 +67,7 @@ export const actions = {
await db.insert(items).values({
name,
folderId: folderId || null,
folderId: folderId,
categoryId,
count,
properties
@ -113,7 +113,7 @@ export const actions = {
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));
await db.update(items).set({ folderId: folderId }).where(eq(items.id, id));
return { success: true };
},
bulkDelete: async ({ request }: { request: Request }) => {
@ -129,7 +129,7 @@ export const actions = {
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));
await db.update(items).set({ folderId: folderId }).where(inArray(items.id, ids));
}
return { success: true };
},

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import ItemModal from '$lib/components/ItemModal.svelte';
import MoveItemModal from '$lib/components/MoveItemModal.svelte';
import DeleteConfirmModal from '$lib/components/DeleteConfirmModal.svelte';
@ -102,10 +103,10 @@
<span>/</span>
<span class="font-medium text-white">{category.name}</span>
</div>
<div class="flex gap-2">
<div class="relative 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"
class="animate-in fade-in slide-in-from-top-2 absolute top-1/2 right-full mr-4 flex -translate-y-1/2 items-center gap-2 whitespace-nowrap duration-200"
>
<span class="text-sm font-medium text-zinc-400">{selectedItems.size} selected</span>
<button
@ -169,7 +170,10 @@
currentFolderId={null}
currentCategoryId={category.id}
user={data.user}
onClose={() => (showItemModal = false)}
onClose={() => {
showItemModal = false;
invalidateAll();
}}
/>
<!-- Move Item Modal -->
@ -205,7 +209,7 @@
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"
class="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 checked:bg-[url('data:image/svg+xml,%3csvg%20viewBox=%270%200%2016%2016%27%20fill=%27white%27%20xmlns=%27http://www.w3.org/2000/svg%27%3e%3cpath%20d=%27M12.207%204.793a1%201%200%20010%201.414l-5%205a1%201%200%2001-1.414%200l-2-2a1%201%200%20011.414-1.414L6.5%209.086l4.293-4.293a1%201%200%20011.414%200z%27/%3e%3c/svg%3e')] checked:bg-[length:75%] 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>
@ -232,7 +236,7 @@
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"
class="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 checked:bg-[url('data:image/svg+xml,%3csvg%20viewBox=%270%200%2016%2016%27%20fill=%27white%27%20xmlns=%27http://www.w3.org/2000/svg%27%3e%3cpath%20d=%27M12.207%204.793a1%201%200%20010%201.414l-5%205a1%201%200%2001-1.414%200l-2-2a1%201%200%20011.414-1.414L6.5%209.086l4.293-4.293a1%201%200%20011.414%200z%27/%3e%3c/svg%3e')] checked:bg-[length:75%] focus:ring-1 focus:ring-red-500 focus:ring-offset-0"
/>
</td>
<td class="px-4 py-2 font-medium text-zinc-200">

View File

@ -3,17 +3,25 @@ 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';
import { getOrSeedHomeFolder } from '$lib/server/db/utils';
export const load = async ({ url }) => {
const currentFolder = null;
const currentFolder = await getOrSeedHomeFolder();
const breadcrumbs: any[] = [];
const searchQuery = url.searchParams.get('search');
// Fetch subfolders (root)
// Fetch subfolders (children of Home)
const subfolders = await db.select().from(folders)
.where(isNull(folders.parentId));
.where(eq(folders.parentId, currentFolder.id));
let folderItemsQuery = db.select({
const conditions = [];
if (searchQuery) {
conditions.push(ilike(items.name, `%${searchQuery}%`));
} else {
conditions.push(eq(items.folderId, currentFolder.id));
}
const folderItemsQuery = db.select({
id: items.id,
name: items.name,
count: items.count,
@ -25,13 +33,8 @@ export const load = async ({ url }) => {
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));
}
.leftJoin(categories, eq(items.categoryId, categories.id))
.where(and(...conditions));
const folderItems = await folderItemsQuery;
@ -72,7 +75,7 @@ export const actions = {
await db.insert(items).values({
name,
folderId: folderId || null,
folderId: folderId,
categoryId,
count,
properties
@ -99,7 +102,7 @@ export const actions = {
};
if (folderId !== undefined) {
updateData.folderId = folderId || null;
updateData.folderId = folderId;
}
await db.update(items)
@ -118,7 +121,7 @@ export const actions = {
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));
await db.update(items).set({ folderId: folderId }).where(eq(items.id, id));
return { success: true };
},
bulkDelete: async ({ request }) => {
@ -134,8 +137,60 @@ export const actions = {
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));
await db.update(items).set({ folderId: folderId }).where(inArray(items.id, ids));
}
return { success: true };
},
updateCount: async ({ 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 }) => {
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 }) => {
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 }) => {
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 };
}
};

View File

@ -1,9 +1,11 @@
<script lang="ts">
import { page } from '$app/stores';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
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';
@ -12,30 +14,33 @@
$: ({ currentFolder, breadcrumbs, subfolders, items, categories } = data);
let folderDialog: HTMLDialogElement;
// 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 openFolderModal() {
folderDialog.showModal();
function openCreateFolderModal() {
editingFolder = null;
showFolderModal = true;
}
function closeFolderModal() {
folderDialog.close();
function openRenameFolderModal(folder: any) {
editingFolder = folder;
showFolderModal = true;
}
function openCreateItemModal() {
@ -51,24 +56,42 @@
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;
}
@ -106,18 +129,15 @@
<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"
>
<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">
<div class="relative 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"
class="animate-in fade-in slide-in-from-top-2 absolute top-1/2 right-full mr-4 flex -translate-y-1/2 items-center gap-2 whitespace-nowrap duration-200"
>
<span class="text-sm font-medium text-zinc-400">{selectedItems.size} selected</span>
<button
@ -164,10 +184,10 @@
{/if}
<button
on:click={openFolderModal}
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 Location
+ New Folder
</button>
{#if currentFolder}
<button
@ -180,44 +200,13 @@
</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>
<!-- Create/Edit Folder Modal -->
<FolderModal
bind:show={showFolderModal}
folder={editingFolder}
parentId={currentFolder?.id}
onClose={() => (showFolderModal = false)}
/>
<!-- Item Modal -->
<ItemModal
@ -226,15 +215,20 @@
{categories}
folders={data.allFolders || []}
currentFolderId={currentFolder?.id}
onClose={() => (showItemModal = false)}
user={data.user}
onClose={() => {
showItemModal = false;
invalidateAll();
}}
/>
<!-- Move Item Modal -->
<!-- Move Item/Folder Modal -->
<MoveItemModal
bind:show={showMoveItemModal}
itemIds={actionItemIds}
itemName={actionItemName}
folders={data.allFolders || []}
type={actionType}
onClose={() => (showMoveItemModal = false)}
/>
@ -243,23 +237,29 @@
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">Locations</h2>
<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}
<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="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="rounded bg-zinc-800 p-2 text-zinc-400 transition-colors group-hover:text-white"
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"
@ -276,10 +276,77 @@
/>
</svg>
</div>
<span class="truncate font-medium text-zinc-300 group-hover:text-white"
<span
class="pointer-events-none z-10 truncate font-medium text-zinc-300 group-hover:text-white"
>{folder.name}</span
>
</a>
<!-- 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>
@ -299,13 +366,13 @@
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"
class="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 checked:bg-[url('data:image/svg+xml,%3csvg%20viewBox=%270%200%2016%2016%27%20fill=%27white%27%20xmlns=%27http://www.w3.org/2000/svg%27%3e%3cpath%20d=%27M12.207%204.793a1%201%200%20010%201.414l-5%205a1%201%200%2001-1.414%200l-2-2a1%201%200%20011.414-1.414L6.5%209.086l4.293-4.293a1%201%200%20011.414%200z%27/%3e%3c/svg%3e')] checked:bg-[length:75%] 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>
@ -322,10 +389,17 @@
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"
class="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 checked:bg-[url('data:image/svg+xml,%3csvg%20viewBox=%270%200%2016%2016%27%20fill=%27white%27%20xmlns=%27http://www.w3.org/2000/svg%27%3e%3cpath%20d=%27M12.207%204.793a1%201%200%20010%201.414l-5%205a1%201%200%2001-1.414%200l-2-2a1%201%200%20011.414-1.414L6.5%209.086l4.293-4.293a1%201%200%20011.414%200z%27/%3e%3c/svg%3e')] checked:bg-[length:75%] 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 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"
@ -334,28 +408,79 @@
</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 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>
{getFormattedValue(key, value)}</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>
@ -450,9 +575,9 @@
/>
</svg>
</div>
<h3 class="text-lg font-medium text-zinc-300">Empty Location</h3>
<h3 class="text-lg font-medium text-zinc-300">Empty Folder</h3>
<p class="mt-1 text-sm text-zinc-500">
This location is empty. Add items or sublocations to get started.
This folder is empty. Add items or subfolders to get started.
</p>
</div>
{/if}

View File

@ -4,17 +4,22 @@ import { eq, and, isNull, inArray, sql } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import { processProperties } from '$lib/utils/propertyUtils';
import { getOrSeedHomeFolder } from '$lib/server/db/utils';
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;
// Start with Home folder as the root parent
const homeFolder = await getOrSeedHomeFolder();
let parentId: string | null = homeFolder.id;
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)
eq(folders.parentId, parentId)
))
.limit(1);
@ -31,9 +36,13 @@ export const load = async ({ params, url }: { params: { path: string }, url: URL
});
}
if (!currentFolder) {
throw error(404, 'Folder not found');
}
// Fetch subfolders
const subfolders = await db.select().from(folders)
.where(currentFolder ? eq(folders.parentId, currentFolder.id) : isNull(folders.parentId));
.where(eq(folders.parentId, currentFolder.id));
// Fetch items
const folderItems = await db.select({
@ -49,7 +58,7 @@ export const load = async ({ params, url }: { params: { path: string }, url: URL
})
.from(items)
.leftJoin(categories, eq(items.categoryId, categories.id))
.where(currentFolder ? eq(items.folderId, currentFolder.id) : isNull(items.folderId));
.where(eq(items.folderId, currentFolder.id));
const allCategories = await db.select().from(categories);
const allFolders = await db.select().from(folders);
@ -79,7 +88,7 @@ export const actions = {
},
createItem: async ({ request }: { request: Request }) => {
const data = await request.formData();
const name = data.get('name') as string;
const name = (data.get('name') as string) || null;
const folderId = data.get('folderId') as string;
const categoryId = data.get('categoryId') as string;
const count = parseInt(data.get('count') as string) || 0;
@ -88,7 +97,7 @@ export const actions = {
await db.insert(items).values({
name,
folderId: folderId || null,
folderId: folderId,
categoryId,
count,
properties
@ -99,7 +108,7 @@ export const actions = {
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 name = (data.get('name') as string) || null;
const categoryId = data.get('categoryId') as string;
const count = parseInt(data.get('count') as string) || 0;
const folderId = data.get('folderId') as string;
@ -115,7 +124,7 @@ export const actions = {
};
if (folderId !== undefined) {
updateData.folderId = folderId || null;
updateData.folderId = folderId;
}
await db.update(items)
@ -134,7 +143,7 @@ export const actions = {
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));
await db.update(items).set({ folderId: folderId }).where(eq(items.id, id));
return { success: true };
},
bulkDelete: async ({ request }: { request: Request }) => {
@ -150,7 +159,7 @@ export const actions = {
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));
await db.update(items).set({ folderId: folderId }).where(inArray(items.id, ids));
}
return { success: true };
},

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import ItemModal from '$lib/components/ItemModal.svelte';
import MoveItemModal from '$lib/components/MoveItemModal.svelte';
import DeleteConfirmModal from '$lib/components/DeleteConfirmModal.svelte';
@ -133,10 +134,10 @@
</a>
{/each}
</div>
<div class="flex gap-2">
<div class="relative 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"
class="animate-in fade-in slide-in-from-top-2 absolute top-1/2 right-full mr-4 flex -translate-y-1/2 items-center gap-2 whitespace-nowrap duration-200"
>
<span class="text-sm font-medium text-zinc-400">{selectedItems.size} selected</span>
<button
@ -215,7 +216,10 @@
folders={data.allFolders || []}
currentFolderId={currentFolder?.id}
user={data.user}
onClose={() => (showItemModal = false)}
onClose={() => {
showItemModal = false;
invalidateAll();
}}
/>
<!-- Move Item/Folder Modal -->
@ -362,7 +366,7 @@
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"
class="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 checked:bg-[url('data:image/svg+xml,%3csvg%20viewBox=%270%200%2016%2016%27%20fill=%27white%27%20xmlns=%27http://www.w3.org/2000/svg%27%3e%3cpath%20d=%27M12.207%204.793a1%201%200%20010%201.414l-5%205a1%201%200%2001-1.414%200l-2-2a1%201%200%20011.414-1.414L6.5%209.086l4.293-4.293a1%201%200%20011.414%200z%27/%3e%3c/svg%3e')] checked:bg-[length:75%] 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>
@ -385,7 +389,7 @@
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"
class="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 checked:bg-[url('data:image/svg+xml,%3csvg%20viewBox=%270%200%2016%2016%27%20fill=%27white%27%20xmlns=%27http://www.w3.org/2000/svg%27%3e%3cpath%20d=%27M12.207%204.793a1%201%200%20010%201.414l-5%205a1%201%200%2001-1.414%200l-2-2a1%201%200%20011.414-1.414L6.5%209.086l4.293-4.293a1%201%200%20011.414%200z%27/%3e%3c/svg%3e')] checked:bg-[length:75%] focus:ring-1 focus:ring-red-500 focus:ring-offset-0"
/>
</td>
<td class="px-4 py-2 font-medium text-zinc-200">

View File

@ -1,6 +1,9 @@
import { fail, redirect } from '@sveltejs/kit';
import { createReservation, updateReservation, deleteReservation } from '$lib/server/reservations';
import { createComment, deleteComment } from '$lib/server/comments';
import { db } from '$lib/server/db';
import { items, reservations } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export const actions = {
reserve: async ({ request, locals }) => {
@ -86,5 +89,38 @@ export const actions = {
} catch (e) {
return fail(500, { error: 'Failed to delete comment' });
}
},
applyReservation: 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 {
// Fetch reservation to get count and itemId
const reservation = await db.select().from(reservations).where(eq(reservations.id, id)).limit(1);
if (reservation.length === 0) return fail(404, { error: 'Reservation not found' });
const { itemId, count: reservedAmount } = reservation[0];
// Fetch item to get current count
const item = await db.select().from(items).where(eq(items.id, itemId)).limit(1);
if (item.length === 0) return fail(404, { error: 'Item not found' });
const currentCount = item[0].count;
const newCount = Math.max(0, currentCount - reservedAmount);
// Update item count
await db.update(items).set({ count: newCount }).where(eq(items.id, itemId));
// Delete reservation
await db.delete(reservations).where(eq(reservations.id, id));
return { success: true, newCount };
} catch (e) {
console.error(e);
return fail(500, { error: 'Failed to apply reservation' });
}
}
};

View File

@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-node';
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */

5
todos
View File

@ -1,5 +0,0 @@
- Folder route
- Fix Submitting items from category view
- Editing Categories
- Add global search
- Add local search in folder & category views

View File

@ -1,6 +1,6 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig, UserConfig } from 'vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
@ -9,7 +9,5 @@ export default defineConfig({
},
server: {
origin: 'https://el.inv.fasttube.de',
host: '0.0.0.0',
port: 443
},
});