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:
@@ -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">← 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; }
|
||||
|
||||
Reference in New Issue
Block a user