Close mutating-action auth bypass; add LCSC part-number autofill
Deploy to LXC / deploy (push) Failing after 13m2s
Deploy to LXC / deploy (push) Failing after 13m2s
Form actions and API endpoints under (app) previously relied only on the layout load's redirect, which SvelteKit never runs before POST actions/+server.ts handlers -- an unauthenticated request could reach any mutating action directly. Gate it centrally in hooks.server.ts instead of patching ~20 files individually. Also adds a part-number lookup box on Add/Edit Part: paste an LCSC code (e.g. C25804) and category/value/voltage/tolerance/mounting/ package auto-fill from LCSC's product data. Digikey is wired as a dispatcher branch, inactive until DIGIKEY_CLIENT_ID/SECRET are set.
This commit is contained in:
@@ -3,6 +3,9 @@ import { validateSession, setSessionCookie, deleteSessionCookie } from '$lib/ser
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// Route IDs are prefixed with their filesystem route group, e.g. "/(app)/devices/[id]".
|
||||||
|
const PROTECTED_ROUTE_PREFIX = '/(app)';
|
||||||
|
|
||||||
export const handleError: HandleServerError = async ({ error: err }) => {
|
export const handleError: HandleServerError = async ({ error: err }) => {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
|
||||||
@@ -64,5 +67,19 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
event.locals.session = null;
|
event.locals.session = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth gate for every request under the (app) route group — not just page
|
||||||
|
// renders. SvelteKit runs form `actions` and `+server.ts` handlers without
|
||||||
|
// first invoking +layout.server.ts `load`, so the layout's redirect-based
|
||||||
|
// guard never ran for POST/PUT/PATCH/DELETE requests; a crafted request
|
||||||
|
// with no session could reach any mutating action directly. GET/HEAD page
|
||||||
|
// requests still fall through to the layout guard for its redirect-to-login
|
||||||
|
// UX; everything else is hard-rejected here.
|
||||||
|
if (event.route.id?.startsWith(PROTECTED_ROUTE_PREFIX) && !event.locals.user) {
|
||||||
|
const method = event.request.method;
|
||||||
|
if (method !== 'GET' && method !== 'HEAD') {
|
||||||
|
error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return resolve(event);
|
return resolve(event);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PartLookupResult } from '$lib/server/partsLookup.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onFound: (result: PartLookupResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onFound }: Props = $props();
|
||||||
|
|
||||||
|
let value = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
let message = $state('');
|
||||||
|
let messageType = $state<'success' | 'error' | ''>('');
|
||||||
|
|
||||||
|
async function lookup() {
|
||||||
|
const pn = value.trim();
|
||||||
|
if (!pn || loading) return;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
message = '';
|
||||||
|
messageType = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/parts/lookup?pn=' + encodeURIComponent(pn));
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.found && data.part) {
|
||||||
|
onFound(data.part);
|
||||||
|
message = 'Auto-filled from LCSC — you can edit any field below.';
|
||||||
|
messageType = 'success';
|
||||||
|
} else {
|
||||||
|
message = 'No match found for that part number — you can still fill in the fields manually.';
|
||||||
|
messageType = 'error';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message = 'Lookup failed — check your connection and try again.';
|
||||||
|
messageType = 'error';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
lookup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<label for="part-lookup" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Part number lookup
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="part-lookup"
|
||||||
|
bind:value
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
placeholder="e.g. C25804 (LCSC)"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={lookup}
|
||||||
|
disabled={loading}
|
||||||
|
class="shrink-0 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Looking up…' : 'Look up'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if message}
|
||||||
|
<p
|
||||||
|
class="mt-2 text-sm {messageType === 'success'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-amber-600 dark:text-amber-400'}"
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import type { PartLookupResult } from './partsLookup.js';
|
||||||
|
|
||||||
|
let cachedToken: { value: string; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
async function getToken(clientId: string, clientSecret: string): Promise<string | null> {
|
||||||
|
if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) {
|
||||||
|
return cachedToken.value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://api.digikey.com/v1/oauth2/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: 'client_credentials'
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(8000)
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const body = await res.json();
|
||||||
|
if (!body?.access_token) return null;
|
||||||
|
cachedToken = {
|
||||||
|
value: body.access_token,
|
||||||
|
expiresAt: Date.now() + (Number(body.expires_in) || 600) * 1000
|
||||||
|
};
|
||||||
|
return cachedToken.value;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupDigikeyPart(pn: string): Promise<PartLookupResult | null> {
|
||||||
|
const clientId = env.DIGIKEY_CLIENT_ID;
|
||||||
|
const clientSecret = env.DIGIKEY_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) return null;
|
||||||
|
|
||||||
|
const token = await getToken(clientId, clientSecret);
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.digikey.com/products/v4/search/${encodeURIComponent(pn)}/productdetails`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-DIGIKEY-Client-Id': clientId,
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(8000)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
const product = body?.Product ?? body;
|
||||||
|
if (!product) return null;
|
||||||
|
|
||||||
|
// Best-effort mapping — UNVERIFIED against a live Digikey response; recheck once real credentials exist.
|
||||||
|
return {
|
||||||
|
mpn: product.ManufacturerProductNumber ?? pn,
|
||||||
|
name: product.Manufacturer?.Name ?? null,
|
||||||
|
category: null,
|
||||||
|
value: null,
|
||||||
|
voltage: null,
|
||||||
|
tolerance: null,
|
||||||
|
power: null,
|
||||||
|
mounting: null,
|
||||||
|
package: null,
|
||||||
|
notes: `Auto-filled from Digikey ${pn}`,
|
||||||
|
sourceUrl: product.ProductUrl ?? null,
|
||||||
|
provider: 'digikey'
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { PartLookupResult } from './partsLookup.js';
|
||||||
|
|
||||||
|
const USER_AGENT =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
const CATEGORY_RULES: [RegExp, string][] = [
|
||||||
|
[/capacitor/, 'Capacitor'],
|
||||||
|
[/resistor/, 'Resistor'],
|
||||||
|
[/inductor|ferrite|choke/, 'Inductor'],
|
||||||
|
[/light emitting diode|\bled\b/, 'LED'],
|
||||||
|
[/diode/, 'Diode'],
|
||||||
|
[/transistor|mosfet|igbt/, 'Transistor'],
|
||||||
|
[/crystal|oscillator|resonator/, 'Crystal / Oscillator'],
|
||||||
|
[/fuse/, 'Fuse'],
|
||||||
|
[/switch|button|tactile/, 'Button / Switch'],
|
||||||
|
[/connector|header|terminal/, 'Connector'],
|
||||||
|
[/circuit|controller|amplifier|microcontroller|regulator|interface|logic|memory|driver|ic\b/, 'IC / Chip']
|
||||||
|
];
|
||||||
|
|
||||||
|
function mapCategory(tags: unknown): string {
|
||||||
|
const joined = Array.isArray(tags) ? tags.join(' ').toLowerCase() : '';
|
||||||
|
for (const [re, cat] of CATEGORY_RULES) {
|
||||||
|
if (re.test(joined)) return cat;
|
||||||
|
}
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPackage(pkg: string | null | undefined): string | null {
|
||||||
|
if (!pkg) return null;
|
||||||
|
// Strip leading letters from codes like "R0603" / "C0402" → "0603" / "0402"; keep irregular ones as-is.
|
||||||
|
const m = pkg.match(/^[A-Za-z]+(\d{3,5})$/);
|
||||||
|
return m ? m[1] : pkg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupLcscPart(code: string): Promise<PartLookupResult | null> {
|
||||||
|
// Validate before building the URL — this is the only untrusted input on the request path (prevents SSRF/path injection).
|
||||||
|
if (!/^C\d+$/i.test(code)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://easyeda.com/api/products/${code}/components`, {
|
||||||
|
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(8000)
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type') ?? '';
|
||||||
|
if (!contentType.includes('application/json')) return null;
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
if (!body?.success || !body.result) return null;
|
||||||
|
|
||||||
|
const result = body.result;
|
||||||
|
const cPara = result?.dataStr?.head?.c_para ?? {};
|
||||||
|
const description: string = typeof result.description === 'string' ? result.description : '';
|
||||||
|
|
||||||
|
const toleranceMatch = description.match(/±\s*([\d.]+\s*%)/);
|
||||||
|
const voltageMatches = description.match(/(\d+(?:\.\d+)?\s?[mkMGT]?V)\b/g);
|
||||||
|
const powerMatch = description.match(/(\d+(?:\.\d+)?\s?[mkM]?W)\b/);
|
||||||
|
|
||||||
|
const value = typeof cPara.Value === 'string' ? cPara.Value.trim() : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
mpn: cPara['Manufacturer Part'] || result.title || code,
|
||||||
|
name: cPara.Manufacturer ? String(cPara.Manufacturer) : null,
|
||||||
|
category: mapCategory(result.tags),
|
||||||
|
value: value || null,
|
||||||
|
tolerance: toleranceMatch ? toleranceMatch[1].replace(/\s+/g, '') : null,
|
||||||
|
voltage: voltageMatches ? voltageMatches[voltageMatches.length - 1] : null,
|
||||||
|
power: powerMatch ? powerMatch[1] : null,
|
||||||
|
mounting: result.SMT === true ? 'SMT' : result.SMT === false ? 'THT' : null,
|
||||||
|
package: mapPackage(cPara.package),
|
||||||
|
notes: `Auto-filled from LCSC ${code}`,
|
||||||
|
sourceUrl: result.lcsc?.url ?? null,
|
||||||
|
provider: 'lcsc'
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { lookupLcscPart } from './lcscLookup.js';
|
||||||
|
import { lookupDigikeyPart } from './digikeyLookup.js';
|
||||||
|
|
||||||
|
export interface PartLookupResult {
|
||||||
|
name: string | null;
|
||||||
|
category: string | null;
|
||||||
|
mpn: string | null;
|
||||||
|
value: string | null;
|
||||||
|
voltage: string | null;
|
||||||
|
tolerance: string | null;
|
||||||
|
power: string | null;
|
||||||
|
mounting: 'THT' | 'SMT' | null;
|
||||||
|
package: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
sourceUrl: string | null;
|
||||||
|
provider: 'lcsc' | 'digikey';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupPartNumber(raw: string): Promise<PartLookupResult | null> {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
if (/^C\d+$/i.test(trimmed)) {
|
||||||
|
return lookupLcscPart(trimmed);
|
||||||
|
}
|
||||||
|
return lookupDigikeyPart(trimmed);
|
||||||
|
}
|
||||||
@@ -2,18 +2,40 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { PART_CATEGORIES } from '$lib/constants.js';
|
import { PART_CATEGORIES } from '$lib/constants.js';
|
||||||
import LocationPicker from '$lib/components/ui/LocationPicker.svelte';
|
import LocationPicker from '$lib/components/ui/LocationPicker.svelte';
|
||||||
|
import PartLookup from '$lib/components/parts/PartLookup.svelte';
|
||||||
|
import type { PartLookupResult } from '$lib/server/partsLookup.js';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
const val = (field: string) => (form?.values as any)?.[field] ?? (data.part as any)[field] ?? '';
|
||||||
|
|
||||||
let category = $state(String(form?.values?.category ?? data.part.category ?? ''));
|
let category = $state(String(form?.values?.category ?? data.part.category ?? ''));
|
||||||
|
let name = $state(String(val('name')));
|
||||||
|
let mpn = $state(String(val('mpn')));
|
||||||
|
let value = $state(String(val('value')));
|
||||||
|
let voltage = $state(String(val('voltage')));
|
||||||
|
let tolerance = $state(String(val('tolerance')));
|
||||||
|
let power = $state(String(val('power')));
|
||||||
|
let mounting = $state(String(val('mounting')));
|
||||||
|
let pkg = $state(String(val('package')));
|
||||||
|
|
||||||
|
function onFound(r: PartLookupResult) {
|
||||||
|
if (r.name != null) name = r.name;
|
||||||
|
if (r.mpn != null) mpn = r.mpn;
|
||||||
|
if (r.category != null) category = r.category;
|
||||||
|
if (r.value != null) value = r.value;
|
||||||
|
if (r.voltage != null) voltage = r.voltage;
|
||||||
|
if (r.tolerance != null) tolerance = r.tolerance;
|
||||||
|
if (r.power != null) power = r.power;
|
||||||
|
if (r.mounting != null) mounting = r.mounting;
|
||||||
|
if (r.package != null) pkg = r.package;
|
||||||
|
}
|
||||||
|
|
||||||
const showCapacitance = $derived(category === 'Capacitor');
|
const showCapacitance = $derived(category === 'Capacitor');
|
||||||
const showResistance = $derived(category === 'Resistor');
|
const showResistance = $derived(category === 'Resistor');
|
||||||
const showVoltage = $derived(['Capacitor', 'Diode', 'LED', 'Transistor', 'IC / Chip', 'Fuse'].includes(category));
|
const showVoltage = $derived(['Capacitor', 'Diode', 'LED', 'Transistor', 'IC / Chip', 'Fuse'].includes(category));
|
||||||
const showPower = $derived(['Resistor', 'Transistor', 'Diode', 'Inductor'].includes(category));
|
const showPower = $derived(['Resistor', 'Transistor', 'Diode', 'Inductor'].includes(category));
|
||||||
const showTolerance = $derived(['Resistor', 'Capacitor', 'Inductor', 'Crystal / Oscillator'].includes(category));
|
const showTolerance = $derived(['Resistor', 'Capacitor', 'Inductor', 'Crystal / Oscillator'].includes(category));
|
||||||
|
|
||||||
const val = (field: string) => (form?.values as any)?.[field] ?? (data.part as any)[field] ?? '';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -27,6 +49,10 @@
|
|||||||
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<PartLookup {onFound} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="POST" use:enhance class="space-y-5">
|
<form method="POST" use:enhance class="space-y-5">
|
||||||
|
|
||||||
<!-- Identity -->
|
<!-- Identity -->
|
||||||
@@ -45,14 +71,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="mpn" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">MPN</label>
|
<label for="mpn" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">MPN</label>
|
||||||
<input type="text" id="mpn" name="mpn" value={val('mpn')}
|
<input type="text" id="mpn" name="mpn" bind:value={mpn}
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-gray-400 font-normal">(optional)</span></label>
|
<label for="name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-gray-400 font-normal">(optional)</span></label>
|
||||||
<input type="text" id="name" name="name" value={val('name')}
|
<input type="text" id="name" name="name" bind:value={name}
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,27 +87,27 @@
|
|||||||
<label for="value" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label for="value" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{#if showCapacitance}Capacitance{:else if showResistance}Resistance{:else}Value{/if}
|
{#if showCapacitance}Capacitance{:else if showResistance}Resistance{:else}Value{/if}
|
||||||
</label>
|
</label>
|
||||||
<input type="text" id="value" name="value" value={val('value')}
|
<input type="text" id="value" name="value" bind:value
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
{#if showVoltage}
|
{#if showVoltage}
|
||||||
<div>
|
<div>
|
||||||
<label for="voltage" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Voltage Rating</label>
|
<label for="voltage" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Voltage Rating</label>
|
||||||
<input type="text" id="voltage" name="voltage" value={val('voltage')}
|
<input type="text" id="voltage" name="voltage" bind:value={voltage}
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if showTolerance}
|
{#if showTolerance}
|
||||||
<div>
|
<div>
|
||||||
<label for="tolerance" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tolerance</label>
|
<label for="tolerance" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tolerance</label>
|
||||||
<input type="text" id="tolerance" name="tolerance" value={val('tolerance')}
|
<input type="text" id="tolerance" name="tolerance" bind:value={tolerance}
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if showPower}
|
{#if showPower}
|
||||||
<div>
|
<div>
|
||||||
<label for="power" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Power Rating</label>
|
<label for="power" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Power Rating</label>
|
||||||
<input type="text" id="power" name="power" value={val('power')}
|
<input type="text" id="power" name="power" bind:value={power}
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -97,13 +123,13 @@
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
{#each ['THT', 'SMT'] as m}
|
{#each ['THT', 'SMT'] as m}
|
||||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
<input type="radio" name="mounting" value={m} checked={val('mounting') === m}
|
<input type="radio" name="mounting" value={m} bind:group={mounting}
|
||||||
class="text-blue-600 focus:ring-blue-500" />
|
class="text-blue-600 focus:ring-blue-500" />
|
||||||
{m}
|
{m}
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<input type="radio" name="mounting" value="" checked={!val('mounting')}
|
<input type="radio" name="mounting" value="" bind:group={mounting}
|
||||||
class="text-blue-600 focus:ring-blue-500" />
|
class="text-blue-600 focus:ring-blue-500" />
|
||||||
Unknown
|
Unknown
|
||||||
</label>
|
</label>
|
||||||
@@ -111,7 +137,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="package" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Package / Size</label>
|
<label for="package" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Package / Size</label>
|
||||||
<input type="text" id="package" name="package" value={val('package')}
|
<input type="text" id="package" name="package" bind:value={pkg}
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,32 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { PART_CATEGORIES } from '$lib/constants.js';
|
import { PART_CATEGORIES } from '$lib/constants.js';
|
||||||
import LocationPicker from '$lib/components/ui/LocationPicker.svelte';
|
import LocationPicker from '$lib/components/ui/LocationPicker.svelte';
|
||||||
|
import PartLookup from '$lib/components/parts/PartLookup.svelte';
|
||||||
|
import type { PartLookupResult } from '$lib/server/partsLookup.js';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
let category = $state(String(form?.values?.category ?? ''));
|
let category = $state(String(form?.values?.category ?? ''));
|
||||||
|
let name = $state(String(form?.values?.name ?? ''));
|
||||||
|
let mpn = $state(String(form?.values?.mpn ?? ''));
|
||||||
|
let value = $state(String(form?.values?.value ?? ''));
|
||||||
|
let voltage = $state(String(form?.values?.voltage ?? ''));
|
||||||
|
let tolerance = $state(String(form?.values?.tolerance ?? ''));
|
||||||
|
let power = $state(String(form?.values?.power ?? ''));
|
||||||
|
let mounting = $state(String(form?.values?.mounting ?? ''));
|
||||||
|
let pkg = $state(String(form?.values?.package ?? ''));
|
||||||
|
|
||||||
|
function onFound(r: PartLookupResult) {
|
||||||
|
if (r.name != null) name = r.name;
|
||||||
|
if (r.mpn != null) mpn = r.mpn;
|
||||||
|
if (r.category != null) category = r.category;
|
||||||
|
if (r.value != null) value = r.value;
|
||||||
|
if (r.voltage != null) voltage = r.voltage;
|
||||||
|
if (r.tolerance != null) tolerance = r.tolerance;
|
||||||
|
if (r.power != null) power = r.power;
|
||||||
|
if (r.mounting != null) mounting = r.mounting;
|
||||||
|
if (r.package != null) pkg = r.package;
|
||||||
|
}
|
||||||
|
|
||||||
const showCapacitance = $derived(category === 'Capacitor');
|
const showCapacitance = $derived(category === 'Capacitor');
|
||||||
const showResistance = $derived(category === 'Resistor');
|
const showResistance = $derived(category === 'Resistor');
|
||||||
@@ -25,6 +47,10 @@
|
|||||||
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<PartLookup {onFound} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="POST" use:enhance class="space-y-5">
|
<form method="POST" use:enhance class="space-y-5">
|
||||||
|
|
||||||
<!-- Identity -->
|
<!-- Identity -->
|
||||||
@@ -44,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="mpn" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">MPN</label>
|
<label for="mpn" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">MPN</label>
|
||||||
<input type="text" id="mpn" name="mpn" value={form?.values?.mpn ?? ''}
|
<input type="text" id="mpn" name="mpn" bind:value={mpn}
|
||||||
placeholder="Manufacturer part number"
|
placeholder="Manufacturer part number"
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +78,7 @@
|
|||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-gray-400 font-normal">(optional)</span></label>
|
<label for="name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-gray-400 font-normal">(optional)</span></label>
|
||||||
<input type="text" id="name" name="name" value={form?.values?.name ?? ''}
|
<input type="text" id="name" name="name" bind:value={name}
|
||||||
placeholder="e.g. Nichicon electrolytic, BC547 NPN"
|
placeholder="e.g. Nichicon electrolytic, BC547 NPN"
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
@@ -62,14 +88,14 @@
|
|||||||
<label for="value" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label for="value" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{#if showCapacitance}Capacitance{:else if showResistance}Resistance{:else}Value{/if}
|
{#if showCapacitance}Capacitance{:else if showResistance}Resistance{:else}Value{/if}
|
||||||
</label>
|
</label>
|
||||||
<input type="text" id="value" name="value" value={form?.values?.value ?? ''}
|
<input type="text" id="value" name="value" bind:value
|
||||||
placeholder={showCapacitance ? 'e.g. 100µF' : showResistance ? 'e.g. 10kΩ' : 'e.g. 1N4148'}
|
placeholder={showCapacitance ? 'e.g. 100µF' : showResistance ? 'e.g. 10kΩ' : 'e.g. 1N4148'}
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
{#if showVoltage}
|
{#if showVoltage}
|
||||||
<div>
|
<div>
|
||||||
<label for="voltage" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Voltage Rating</label>
|
<label for="voltage" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Voltage Rating</label>
|
||||||
<input type="text" id="voltage" name="voltage" value={form?.values?.voltage ?? ''}
|
<input type="text" id="voltage" name="voltage" bind:value={voltage}
|
||||||
placeholder="e.g. 16V, 50V"
|
placeholder="e.g. 16V, 50V"
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +103,7 @@
|
|||||||
{#if showTolerance}
|
{#if showTolerance}
|
||||||
<div>
|
<div>
|
||||||
<label for="tolerance" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tolerance</label>
|
<label for="tolerance" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tolerance</label>
|
||||||
<input type="text" id="tolerance" name="tolerance" value={form?.values?.tolerance ?? ''}
|
<input type="text" id="tolerance" name="tolerance" bind:value={tolerance}
|
||||||
placeholder="e.g. 5%, 10%, 20%"
|
placeholder="e.g. 5%, 10%, 20%"
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +111,7 @@
|
|||||||
{#if showPower}
|
{#if showPower}
|
||||||
<div>
|
<div>
|
||||||
<label for="power" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Power Rating</label>
|
<label for="power" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Power Rating</label>
|
||||||
<input type="text" id="power" name="power" value={form?.values?.power ?? ''}
|
<input type="text" id="power" name="power" bind:value={power}
|
||||||
placeholder="e.g. 0.25W, 1W"
|
placeholder="e.g. 0.25W, 1W"
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
@@ -103,13 +129,13 @@
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
{#each ['THT', 'SMT'] as m}
|
{#each ['THT', 'SMT'] as m}
|
||||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
<input type="radio" name="mounting" value={m} checked={form?.values?.mounting === m}
|
<input type="radio" name="mounting" value={m} bind:group={mounting}
|
||||||
class="text-blue-600 focus:ring-blue-500" />
|
class="text-blue-600 focus:ring-blue-500" />
|
||||||
{m}
|
{m}
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<input type="radio" name="mounting" value="" checked={!form?.values?.mounting}
|
<input type="radio" name="mounting" value="" bind:group={mounting}
|
||||||
class="text-blue-600 focus:ring-blue-500" />
|
class="text-blue-600 focus:ring-blue-500" />
|
||||||
Unknown
|
Unknown
|
||||||
</label>
|
</label>
|
||||||
@@ -117,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="package" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Package / Size</label>
|
<label for="package" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Package / Size</label>
|
||||||
<input type="text" id="package" name="package" value={form?.values?.package ?? ''}
|
<input type="text" id="package" name="package" bind:value={pkg}
|
||||||
placeholder="e.g. 0805, DIP-8, TO-92, Radial 5mm"
|
placeholder="e.g. 0805, DIP-8, TO-92, Radial 5mm"
|
||||||
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { lookupPartNumber } from '$lib/server/partsLookup.js';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not authenticated');
|
||||||
|
|
||||||
|
const pn = url.searchParams.get('pn') ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await lookupPartNumber(pn);
|
||||||
|
if (result) return json({ found: true, part: result });
|
||||||
|
return json({ found: false });
|
||||||
|
} catch {
|
||||||
|
return json({ found: false, error: 'lookup_failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user