feat: add eslint, image upload, tests, splash tagline, security hooks

- Restore CLAUDE.md with project conventions
- ESLint config with vue3-recommended + typescript
- Image upload endpoint (POST /api/admin/upload) with 5MB limit
- Admin product form now supports image upload/preview/removal
- Vitest config + 19 tests (crypto, validation, btcpay webhook, types)
- Restore .claude/ security hooks (block-risky-bash, protect-files)
- Logo splash now shows "EVERYTHING YOU LOVE IS A PSYOP" tagline
- Add .vite/ to gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-17 00:47:42 +00:00
parent 54500a68e6
commit 814957cd37
17 changed files with 1199 additions and 116 deletions

View File

@@ -12,41 +12,129 @@ const router = useRouter()
const isEdit = computed(() => route.name === 'admin-product-edit')
const isLoading = ref(false)
const isSaving = ref(false)
const isUploading = ref(false)
const errorMessage = ref('')
const name = ref('')
const slug = ref('')
const description = ref('')
const priceSats = ref(0)
const category = ref('general')
const sizes = ref<SizeStock[]>([{ size: 'S', stock: 0 }, { size: 'M', stock: 0 }, { size: 'L', stock: 0 }, { size: 'XL', stock: 0 }])
const images = ref<string[]>([])
const sizes = ref<SizeStock[]>([
{ size: 'S', stock: 0 },
{ size: 'M', stock: 0 },
{ size: 'L', stock: 0 },
{ size: 'XL', stock: 0 },
])
onMounted(async () => {
if (isEdit.value && route.params.id) {
isLoading.value = true
try {
const res = await api.get(`/api/admin/products`)
const res = await api.get('/api/admin/products')
if (res.ok) {
const products = await api.json<Product[]>(res)
const product = products.find((p) => p.id === route.params.id)
if (product) { name.value = product.name; slug.value = product.slug; description.value = product.description; priceSats.value = product.priceSats; category.value = product.category; sizes.value = product.sizes }
if (product) {
name.value = product.name
slug.value = product.slug
description.value = product.description
priceSats.value = product.priceSats
category.value = product.category
images.value = product.images
sizes.value = product.sizes
}
}
} finally { isLoading.value = false }
} finally {
isLoading.value = false
}
}
})
function addSize() { sizes.value.push({ size: '', stock: 0 }) }
function removeSize(index: number) { sizes.value.splice(index, 1) }
function autoSlug() { if (!isEdit.value) slug.value = name.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') }
function addSize() {
sizes.value.push({ size: '', stock: 0 })
}
function removeSize(index: number) {
sizes.value.splice(index, 1)
}
function removeImage(index: number) {
images.value.splice(index, 1)
}
function autoSlug() {
if (!isEdit.value) {
slug.value = name.value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
}
async function handleUpload(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
isUploading.value = true
try {
const formData = new FormData()
for (const file of files) {
formData.append('images', file)
}
const res = await fetch('/api/admin/upload', {
method: 'POST',
body: formData,
credentials: 'same-origin',
})
if (res.ok) {
const data = (await res.json()) as { urls: string[] }
images.value.push(...data.urls)
} else {
const err = (await res.json()) as ApiError
errorMessage.value = err.error.message
}
} catch {
errorMessage.value = 'Image upload failed'
} finally {
isUploading.value = false
input.value = ''
}
}
async function handleSave() {
isSaving.value = true; errorMessage.value = ''
const data = { name: name.value, slug: slug.value, description: description.value, priceSats: priceSats.value, category: category.value, sizes: sizes.value, images: [] }
isSaving.value = true
errorMessage.value = ''
const data = {
name: name.value,
slug: slug.value,
description: description.value,
priceSats: priceSats.value,
category: category.value,
sizes: sizes.value,
images: images.value,
}
try {
const res = isEdit.value ? await api.put(`/api/admin/products/${route.params.id}`, data) : await api.post('/api/admin/products', data)
if (res.ok) router.push({ name: 'admin-products' })
else { const err = await api.json<ApiError>(res); errorMessage.value = err.error.message }
} catch { errorMessage.value = 'Failed to save product' }
finally { isSaving.value = false }
const res = isEdit.value
? await api.put(`/api/admin/products/${route.params.id}`, data)
: await api.post('/api/admin/products', data)
if (res.ok) {
router.push({ name: 'admin-products' })
} else {
const err = await api.json<ApiError>(res)
errorMessage.value = err.error.message
}
} catch {
errorMessage.value = 'Failed to save product'
} finally {
isSaving.value = false
}
}
</script>
@@ -54,25 +142,71 @@ async function handleSave() {
<div>
<router-link :to="{ name: 'admin-products' }" class="back-link">&larr; Products</router-link>
<h1>{{ isEdit ? 'Edit Product' : 'New Product' }}</h1>
<LoadingSpinner v-if="isLoading" />
<form v-else class="product-form" @submit.prevent="handleSave">
<div class="form-grid">
<div class="field"><label>Name</label><GlassInput v-model="name" placeholder="Product name" is-required @blur="autoSlug" /></div>
<div class="field"><label>Slug</label><GlassInput v-model="slug" placeholder="url-friendly-name" is-required /></div>
<div class="field"><label>Price (sats)</label><input v-model.number="priceSats" type="number" min="1" step="1" class="glass-input" required /></div>
<div class="field"><label>Category</label><GlassInput v-model="category" placeholder="e.g. tops, bottoms, accessories" /></div>
<div class="field">
<label>Name</label>
<GlassInput v-model="name" placeholder="Product name" is-required @blur="autoSlug" />
</div>
<div class="field">
<label>Slug</label>
<GlassInput v-model="slug" placeholder="url-friendly-name" is-required />
</div>
<div class="field">
<label>Price (sats)</label>
<input v-model.number="priceSats" type="number" min="1" step="1" class="glass-input" required />
</div>
<div class="field">
<label>Category</label>
<GlassInput v-model="category" placeholder="e.g. tops, bottoms, accessories" />
</div>
</div>
<div class="field"><label>Description</label><textarea v-model="description" class="glass-input" rows="4" placeholder="Product description..." /></div>
<div class="field">
<div class="sizes-header"><label>Sizes & Stock</label><button type="button" class="add-size-btn" @click="addSize">+ Add Size</button></div>
<label>Description</label>
<textarea v-model="description" class="glass-input" rows="4" placeholder="Product description..." />
</div>
<div class="field">
<label>Images</label>
<div v-if="images.length" class="image-preview-grid">
<div v-for="(img, i) in images" :key="img" class="image-preview">
<img :src="img" :alt="`Product image ${i + 1}`" />
<button type="button" class="image-remove" @click="removeImage(i)">x</button>
</div>
</div>
<label class="upload-btn btn btn-ghost">
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/avif"
multiple
class="upload-input"
@change="handleUpload"
/>
{{ isUploading ? 'Uploading...' : 'Upload Images' }}
</label>
</div>
<div class="field">
<div class="sizes-header">
<label>Sizes & Stock</label>
<button type="button" class="add-size-btn" @click="addSize">+ Add Size</button>
</div>
<div v-for="(s, i) in sizes" :key="i" class="size-row">
<input v-model="s.size" class="glass-input size-input" placeholder="Size" />
<input v-model.number="s.stock" type="number" min="0" step="1" class="glass-input stock-input" placeholder="Stock" />
<button type="button" class="remove-btn" @click="removeSize(i)">x</button>
</div>
</div>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<GlassButton :is-disabled="isSaving">{{ isSaving ? 'Saving...' : (isEdit ? 'Update Product' : 'Create Product') }}</GlassButton>
<GlassButton :is-disabled="isSaving">
{{ isSaving ? 'Saving...' : (isEdit ? 'Update Product' : 'Create Product') }}
</GlassButton>
</form>
</div>
</template>
@@ -86,6 +220,55 @@ h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; font-size: 0.8125rem; font-weight: 500; color: var(--text-secondary); margin-bottom: 0.375rem; }
.field textarea { resize: vertical; font-family: inherit; }
.image-preview-grid {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.image-preview {
position: relative;
width: 80px;
height: 100px;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--glass-border);
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-remove {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--error);
color: #fff;
border: none;
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.upload-btn {
cursor: pointer;
display: inline-flex;
}
.upload-input {
display: none;
}
.sizes-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.375rem; }
.add-size-btn { background: none; border: none; color: var(--accent); font-size: 0.8125rem; cursor: pointer; }
.size-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; align-items: center; }