Initial commit: buildfor_life_repair inventory system
SvelteKit + PostgreSQL app for tracking vintage computers, audio equipment, components, and installation history. Features device/component CRUD, operation logs, QR code labels, global search, image uploads, and dark mode. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
counts: { devices: number; components: number; needsRepair: number };
|
||||
}
|
||||
|
||||
let { open, onToggle, counts }: Props = $props();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
href: '/',
|
||||
label: 'Dashboard',
|
||||
icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'
|
||||
},
|
||||
{
|
||||
href: '/devices',
|
||||
label: 'Devices',
|
||||
badge: counts.devices,
|
||||
icon: 'M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2'
|
||||
},
|
||||
{
|
||||
href: '/components',
|
||||
label: 'Components',
|
||||
badge: counts.components,
|
||||
icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z'
|
||||
},
|
||||
{
|
||||
href: '/installations',
|
||||
label: 'Install Log',
|
||||
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01'
|
||||
},
|
||||
{
|
||||
href: '/locations',
|
||||
label: 'Locations',
|
||||
icon: 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z'
|
||||
},
|
||||
{
|
||||
href: '/gallery',
|
||||
label: 'Gallery',
|
||||
icon: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="flex w-64 flex-col border-r border-gray-200 bg-white transition-transform duration-200 dark:border-gray-700 dark:bg-gray-800 {open
|
||||
? 'translate-x-0'
|
||||
: '-translate-x-full'} fixed inset-y-0 left-0 z-30 lg:relative lg:translate-x-0"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex h-14 items-center border-b border-gray-200 px-4 dark:border-gray-700">
|
||||
<a href="/" class="text-lg font-bold text-gray-900 dark:text-white">B4L Repair</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto px-3 py-4">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
|
||||
</svg>
|
||||
{item.label}
|
||||
{#if item.badge}
|
||||
<span class="ml-auto rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
{item.badge}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{#if counts.needsRepair > 0}
|
||||
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Attention
|
||||
</div>
|
||||
<a
|
||||
href="/devices?condition=needs-repair"
|
||||
class="mb-0.5 flex items-center gap-2 rounded-md px-3 py-2 text-sm text-amber-700 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-900/20"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
Needs Repair
|
||||
<span class="ml-auto rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
{counts.needsRepair}
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Backdrop for mobile -->
|
||||
{#if open}
|
||||
<button
|
||||
class="fixed inset-0 z-20 bg-black/30 lg:hidden"
|
||||
onclick={onToggle}
|
||||
aria-label="Close sidebar"
|
||||
></button>
|
||||
{/if}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { theme } from '$lib/stores/theme.svelte.js';
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={() => theme.toggle()}
|
||||
class="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
title="Toggle {theme.isDark ? 'light' : 'dark'} mode"
|
||||
>
|
||||
{#if theme.isDark}
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
fetchUrl: string;
|
||||
extraParams?: Record<string, string>;
|
||||
}
|
||||
|
||||
let { id, name, value = $bindable(), placeholder = '', fetchUrl, extraParams = {} }: Props = $props();
|
||||
|
||||
let suggestions = $state<string[]>([]);
|
||||
let showDropdown = $state(false);
|
||||
let activeIndex = $state(-1);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function fetchSuggestions(query: string) {
|
||||
clearTimeout(debounceTimer);
|
||||
if (!query || query.length < 1) {
|
||||
suggestions = [];
|
||||
showDropdown = false;
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const params = new URLSearchParams({ q: query, ...extraParams });
|
||||
const res = await fetch(`${fetchUrl}?${params}`);
|
||||
if (res.ok) {
|
||||
suggestions = await res.json();
|
||||
showDropdown = suggestions.length > 0;
|
||||
activeIndex = -1;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value;
|
||||
value = val;
|
||||
fetchSuggestions(val);
|
||||
}
|
||||
|
||||
function select(suggestion: string) {
|
||||
value = suggestion;
|
||||
showDropdown = false;
|
||||
suggestions = [];
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!showDropdown) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex = Math.min(activeIndex + 1, suggestions.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex = Math.max(activeIndex - 1, -1);
|
||||
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
select(suggestions[activeIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
showDropdown = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Delay to allow click on suggestion
|
||||
setTimeout(() => { showDropdown = false; }, 150);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
{id}
|
||||
{name}
|
||||
{value}
|
||||
{placeholder}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
onfocus={() => { if (suggestions.length > 0) showDropdown = true; }}
|
||||
onblur={handleBlur}
|
||||
autocomplete="off"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
/>
|
||||
|
||||
{#if showDropdown}
|
||||
<ul class="absolute z-10 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-gray-200 bg-white shadow-md dark:border-gray-600 dark:bg-gray-700">
|
||||
{#each suggestions as suggestion, i}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-600
|
||||
{i === activeIndex ? 'bg-gray-100 dark:bg-gray-600' : ''}"
|
||||
onmousedown={() => select(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user