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 { 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 }) => {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
|
||||
@@ -64,5 +67,19 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -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 { PART_CATEGORIES } from '$lib/constants.js';
|
||||
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();
|
||||
|
||||
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 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 showResistance = $derived(category === 'Resistor');
|
||||
const showVoltage = $derived(['Capacitor', 'Diode', 'LED', 'Transistor', 'IC / Chip', 'Fuse'].includes(category));
|
||||
const showPower = $derived(['Resistor', 'Transistor', 'Diode', 'Inductor'].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>
|
||||
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<div class="mb-5">
|
||||
<PartLookup {onFound} />
|
||||
</div>
|
||||
|
||||
<form method="POST" use:enhance class="space-y-5">
|
||||
|
||||
<!-- Identity -->
|
||||
@@ -45,14 +71,14 @@
|
||||
</div>
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
@@ -61,27 +87,27 @@
|
||||
<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}
|
||||
</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" />
|
||||
</div>
|
||||
{#if showVoltage}
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if showTolerance}
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if showPower}
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -97,13 +123,13 @@
|
||||
<div class="flex gap-3">
|
||||
{#each ['THT', 'SMT'] as m}
|
||||
<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" />
|
||||
{m}
|
||||
</label>
|
||||
{/each}
|
||||
<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" />
|
||||
Unknown
|
||||
</label>
|
||||
@@ -111,7 +137,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,32 @@
|
||||
import { enhance } from '$app/forms';
|
||||
import { PART_CATEGORIES } from '$lib/constants.js';
|
||||
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 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 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>
|
||||
{/if}
|
||||
|
||||
<div class="mb-5">
|
||||
<PartLookup {onFound} />
|
||||
</div>
|
||||
|
||||
<form method="POST" use:enhance class="space-y-5">
|
||||
|
||||
<!-- Identity -->
|
||||
@@ -44,7 +70,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<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"
|
||||
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>
|
||||
@@ -52,7 +78,7 @@
|
||||
|
||||
<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>
|
||||
<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"
|
||||
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>
|
||||
@@ -62,14 +88,14 @@
|
||||
<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}
|
||||
</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'}
|
||||
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>
|
||||
{#if showVoltage}
|
||||
<div>
|
||||
<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"
|
||||
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>
|
||||
@@ -77,7 +103,7 @@
|
||||
{#if showTolerance}
|
||||
<div>
|
||||
<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%"
|
||||
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>
|
||||
@@ -85,7 +111,7 @@
|
||||
{#if showPower}
|
||||
<div>
|
||||
<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"
|
||||
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>
|
||||
@@ -103,13 +129,13 @@
|
||||
<div class="flex gap-3">
|
||||
{#each ['THT', 'SMT'] as m}
|
||||
<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" />
|
||||
{m}
|
||||
</label>
|
||||
{/each}
|
||||
<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" />
|
||||
Unknown
|
||||
</label>
|
||||
@@ -117,7 +143,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<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"
|
||||
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>
|
||||
|
||||
@@ -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