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:
2026-04-06 17:11:05 +07:00
commit 6f0e0ad6c6
64 changed files with 8996 additions and 0 deletions
+104
View File
@@ -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>