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:
32
.claude/hooks/block-risky-bash.sh
Executable file
32
.claude/hooks/block-risky-bash.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Block dangerous bash commands
|
||||
|
||||
INPUT=$(cat)
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
|
||||
|
||||
if [ -z "$COMMAND" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Block destructive operations
|
||||
BLOCKED_PATTERNS=(
|
||||
"rm -rf"
|
||||
"git reset --hard"
|
||||
"git push -f"
|
||||
"git push --force"
|
||||
"git clean -fd"
|
||||
"chmod -R 777"
|
||||
":(){ :|:& };:"
|
||||
"mkfs"
|
||||
"> /dev/"
|
||||
"dd if="
|
||||
)
|
||||
|
||||
for pattern in "${BLOCKED_PATTERNS[@]}"; do
|
||||
if echo "$COMMAND" | grep -qF "$pattern"; then
|
||||
echo "Destructive ${pattern%% *} blocked by security hook" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
exit 0
|
||||
46
.claude/hooks/protect-files.sh
Executable file
46
.claude/hooks/protect-files.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# Protect sensitive files from being edited
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
|
||||
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get the project directory
|
||||
PROJECT_DIR="$CLAUDE_PROJECT_DIR"
|
||||
if [ -z "$PROJECT_DIR" ]; then
|
||||
PROJECT_DIR="$(pwd)"
|
||||
fi
|
||||
|
||||
# Block edits to .git internals
|
||||
if echo "$FILE_PATH" | grep -q '\.git/'; then
|
||||
echo "Edit blocked: path matches protected pattern (.git/)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Block .env files
|
||||
if echo "$FILE_PATH" | grep -qE '\.env($|\.)'; then
|
||||
echo "Edit blocked: path matches protected pattern (.env)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Block node_modules
|
||||
if echo "$FILE_PATH" | grep -q 'node_modules/'; then
|
||||
echo "Edit blocked: path matches protected pattern (node_modules/)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Block files outside project directory
|
||||
REAL_PROJECT=$(cd "$PROJECT_DIR" 2>/dev/null && pwd -P)
|
||||
REAL_FILE_DIR=$(cd "$(dirname "$FILE_PATH")" 2>/dev/null && pwd -P)
|
||||
|
||||
if [ -n "$REAL_PROJECT" ] && [ -n "$REAL_FILE_DIR" ]; then
|
||||
case "$REAL_FILE_DIR" in
|
||||
"$REAL_PROJECT"*) ;;
|
||||
*) echo "Edit blocked: path is outside project directory" >&2; exit 2 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
exit 0
|
||||
24
.claude/settings.json
Normal file
24
.claude/settings.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-risky-bash.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ __pycache__/
|
||||
.DS_Store
|
||||
loop/loop.log
|
||||
data/
|
||||
.vite/
|
||||
|
||||
125
CLAUDE.md
Normal file
125
CLAUDE.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# CLAUDE.md -- Antonym
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
- **Open source only** -- MIT/Apache-2.0 dependencies
|
||||
- **Privacy-first** -- no tracking, no telemetry
|
||||
- **Bitcoin only** -- sats/Lightning/Cashu for payments, never fiat, never altcoins
|
||||
- **Quality over speed** -- working code, tested, documented
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
pnpm dev # Run app dev server + API server
|
||||
pnpm dev:app # Vite dev server only
|
||||
pnpm dev:server # Express API only
|
||||
pnpm build # Build frontend (vite)
|
||||
pnpm typecheck # Type-check all packages (vue-tsc + tsc)
|
||||
pnpm lint # Lint all packages (eslint)
|
||||
pnpm test # Run tests (vitest)
|
||||
pnpm clean # Remove dist/ directory
|
||||
```
|
||||
|
||||
Dev server: `http://localhost:3333` | API server: `http://localhost:3141`
|
||||
|
||||
## Vue 3 Conventions
|
||||
|
||||
**Always use `<script setup lang="ts">`** -- never Options API.
|
||||
|
||||
### Script section ordering
|
||||
|
||||
Imports -> Props (`defineProps`) -> Emits (`defineEmits`) -> Reactive state -> Computed -> Watchers -> Methods -> Lifecycle hooks -> `defineExpose`
|
||||
|
||||
### Naming
|
||||
|
||||
| Thing | Convention | Example |
|
||||
|-------|-----------|---------|
|
||||
| Components | PascalCase | `ProductCard.vue` |
|
||||
| Composables | camelCase, `use` prefix | `useTheme.ts` |
|
||||
| Props (JS) | camelCase | `projectName` |
|
||||
| Props (template) | kebab-case | `project-name` |
|
||||
| Boolean props | `is`/`has`/`can`/`should` prefix | `isVisible`, `canEdit` |
|
||||
| Emits (template) | kebab-case with colon namespacing | `project:updated` |
|
||||
| Stores | camelCase, `use` prefix, `Store` suffix | `useSettingsStore` |
|
||||
|
||||
### Reactive state rules
|
||||
|
||||
- `ref()` for primitives, `reactive()` for objects
|
||||
- `computed()` for derived values -- no side effects in computed
|
||||
- `shallowRef()` for large collections/objects not requiring deep reactivity
|
||||
- Always use unique IDs for `:key` -- never array index
|
||||
|
||||
### Props
|
||||
|
||||
Always use object-style with type annotations, never array-style:
|
||||
|
||||
```ts
|
||||
// Correct
|
||||
defineProps<{ title: string; count?: number }>()
|
||||
|
||||
// Wrong
|
||||
defineProps(['title', 'count'])
|
||||
```
|
||||
|
||||
### Performance
|
||||
|
||||
- Lazy load with `defineAsyncComponent` for non-critical components
|
||||
- Use `onErrorCaptured` for error boundaries
|
||||
- Always handle loading/error/data states in async operations
|
||||
|
||||
## Design System
|
||||
|
||||
This project uses the **Glassmorphism** design system.
|
||||
Full design system specification is in the `design-glassmorphism` skill (global).
|
||||
If the skill is not loaded, copy it from `~/.claude/skills/design-glassmorphism/`.
|
||||
|
||||
### Quick Reference -- Colors
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Core Tokens
|
||||
```css
|
||||
--bg-primary: #0A0A0A;
|
||||
--bg-secondary: #1A1A1A;
|
||||
--bg-tertiary: #141414;
|
||||
--accent: #F7931A;
|
||||
--accent-hover: #e8841a;
|
||||
--text-primary: rgba(255, 255, 255, 0.9);
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-muted: rgba(255, 255, 255, 0.6);
|
||||
--text-placeholder: rgba(255, 255, 255, 0.25);
|
||||
--text-interactive: rgba(255, 255, 255, 0.7);
|
||||
--success: #4ade80;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--info: #3b82f6;
|
||||
```
|
||||
|
||||
### Glass Tokens
|
||||
```css
|
||||
--glass-bg: rgba(0, 0, 0, 0.5);
|
||||
--glass-bg-strong: rgba(0, 0, 0, 0.75);
|
||||
--glass-bg-darker: rgba(0, 0, 0, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.18);
|
||||
--glass-highlight: rgba(255, 255, 255, 0.22);
|
||||
--glass-blur: 18px;
|
||||
--glass-blur-strong: 24px;
|
||||
```
|
||||
|
||||
### Light Mode Variant
|
||||
```css
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #FAFAFA;
|
||||
--bg-secondary: #F0F0F0;
|
||||
--text-primary: #0A0A0A;
|
||||
--glass-bg: rgba(255, 255, 255, 0.5);
|
||||
--glass-bg-strong: rgba(255, 255, 255, 0.65);
|
||||
--glass-border: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
```
|
||||
|
||||
## Git Conventions
|
||||
|
||||
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`
|
||||
- Branch naming: `feature/`, `bugfix/`, `release/`
|
||||
- Never commit secrets, .env files, or API keys
|
||||
47
eslint.config.js
Normal file
47
eslint.config.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import js from '@eslint/js'
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
import vuePlugin from 'eslint-plugin-vue'
|
||||
import vueParser from 'vue-eslint-parser'
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
},
|
||||
plugins: { '@typescript-eslint': tsPlugin },
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'no-undef': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
languageOptions: {
|
||||
parser: vueParser,
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: { vue: vuePlugin, '@typescript-eslint': tsPlugin },
|
||||
rules: {
|
||||
...vuePlugin.configs['vue3-recommended'].rules,
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/html-self-closing': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'no-undef': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/', 'node_modules/', '*.config.js'],
|
||||
},
|
||||
]
|
||||
45
package.json
45
package.json
@@ -16,36 +16,41 @@
|
||||
"clean": "rm -r dist/"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"pinia": "^2.3.1",
|
||||
"@vueuse/core": "^12.7.0",
|
||||
"express": "^5.1.0",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"helmet": "^8.1.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"nodemailer": "^6.10.1",
|
||||
"nostr-tools": "^2.12.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"helmet": "^8.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"cookie-parser": "^1.4.7"
|
||||
"pinia": "^2.3.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"vite": "^6.3.5",
|
||||
"vue-tsc": "^2.2.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"vitest": "^3.1.1",
|
||||
"tsx": "^4.19.4",
|
||||
"concurrently": "^9.1.2",
|
||||
"@types/express": "^5.0.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.2",
|
||||
"@types/node": "^22.14.1",
|
||||
"eslint": "^9.24.0"
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
||||
"@typescript-eslint/parser": "^8.57.1",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.1.1",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"vue-tsc": "^2.2.8"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependenciesFile": "",
|
||||
|
||||
301
pnpm-lock.yaml
generated
301
pnpm-lock.yaml
generated
@@ -45,6 +45,9 @@ importers:
|
||||
specifier: ^4.5.0
|
||||
version: 4.6.4(vue@3.5.30(typescript@5.9.3))
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1(eslint@9.39.4(jiti@2.6.1))
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
|
||||
@@ -66,6 +69,12 @@ importers:
|
||||
'@types/nodemailer':
|
||||
specifier: ^6.4.17
|
||||
version: 6.4.23
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^8.57.1
|
||||
version: 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^8.57.1
|
||||
version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.2.3
|
||||
version: 5.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(vue@3.5.30(typescript@5.9.3))
|
||||
@@ -75,6 +84,9 @@ importers:
|
||||
eslint:
|
||||
specifier: ^9.24.0
|
||||
version: 9.39.4(jiti@2.6.1)
|
||||
eslint-plugin-vue:
|
||||
specifier: ^10.8.0
|
||||
version: 10.8.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)))
|
||||
tailwindcss:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
@@ -90,6 +102,9 @@ importers:
|
||||
vitest:
|
||||
specifier: ^3.1.1
|
||||
version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
|
||||
vue-eslint-parser:
|
||||
specifier: ^10.4.0
|
||||
version: 10.4.0(eslint@9.39.4(jiti@2.6.1))
|
||||
vue-tsc:
|
||||
specifier: ^2.2.8
|
||||
version: 2.2.12(typescript@5.9.3)
|
||||
@@ -451,6 +466,15 @@ packages:
|
||||
resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/js@10.0.1':
|
||||
resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
peerDependencies:
|
||||
eslint: ^10.0.0
|
||||
peerDependenciesMeta:
|
||||
eslint:
|
||||
optional: true
|
||||
|
||||
'@eslint/js@9.39.4':
|
||||
resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -807,6 +831,65 @@ packages:
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.57.1':
|
||||
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.57.1
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/parser@8.57.1':
|
||||
resolution: {integrity: sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.57.1':
|
||||
resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/scope-manager@8.57.1':
|
||||
resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.57.1':
|
||||
resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.57.1':
|
||||
resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/types@8.57.1':
|
||||
resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.57.1':
|
||||
resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/utils@8.57.1':
|
||||
resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.57.1':
|
||||
resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4':
|
||||
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -942,6 +1025,10 @@ packages:
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
balanced-match@4.0.4:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -958,12 +1045,19 @@ packages:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
|
||||
brace-expansion@5.0.4:
|
||||
resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
@@ -1052,6 +1146,11 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
@@ -1151,6 +1250,20 @@ packages:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
eslint-plugin-vue@10.8.0:
|
||||
resolution: {integrity: sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0
|
||||
'@typescript-eslint/parser': ^7.0.0 || ^8.0.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
vue-eslint-parser: ^10.0.0
|
||||
peerDependenciesMeta:
|
||||
'@stylistic/eslint-plugin':
|
||||
optional: true
|
||||
'@typescript-eslint/parser':
|
||||
optional: true
|
||||
|
||||
eslint-scope@8.4.0:
|
||||
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1163,6 +1276,10 @@ packages:
|
||||
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
eslint-visitor-keys@5.0.1:
|
||||
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
eslint@9.39.4:
|
||||
resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1342,6 +1459,10 @@ packages:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
ignore@7.0.5:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1516,6 +1637,10 @@ packages:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
minimatch@10.2.4:
|
||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
@@ -1574,6 +1699,9 @@ packages:
|
||||
nostr-wasm@0.1.0:
|
||||
resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==}
|
||||
|
||||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1646,6 +1774,10 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.5.8:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1861,6 +1993,12 @@ packages:
|
||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||
hasBin: true
|
||||
|
||||
ts-api-utils@2.4.0:
|
||||
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
|
||||
engines: {node: '>=18.12'}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -1989,6 +2127,12 @@ packages:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-eslint-parser@10.4.0:
|
||||
resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
|
||||
vue-router@4.6.4:
|
||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||
peerDependencies:
|
||||
@@ -2029,6 +2173,10 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
xml-name-validator@4.0.0:
|
||||
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2253,6 +2401,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@eslint/js@10.0.1(eslint@9.39.4(jiti@2.6.1))':
|
||||
optionalDependencies:
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
|
||||
'@eslint/js@9.39.4': {}
|
||||
|
||||
'@eslint/object-schema@2.1.7': {}
|
||||
@@ -2526,6 +2678,97 @@ snapshots:
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.57.1
|
||||
'@typescript-eslint/type-utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.57.1
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.57.1
|
||||
'@typescript-eslint/types': 8.57.1
|
||||
'@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.57.1
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.57.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.57.1
|
||||
debug: 4.4.3
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.57.1':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.57.1
|
||||
'@typescript-eslint/visitor-keys': 8.57.1
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.57.1
|
||||
'@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/types@8.57.1': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.57.1(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.57.1
|
||||
'@typescript-eslint/visitor-keys': 8.57.1
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.4
|
||||
semver: 7.7.4
|
||||
tinyglobby: 0.2.15
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||
'@typescript-eslint/scope-manager': 8.57.1
|
||||
'@typescript-eslint/types': 8.57.1
|
||||
'@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.57.1':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.57.1
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(vue@3.5.30(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
|
||||
@@ -2708,6 +2951,8 @@ snapshots:
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
better-sqlite3@11.10.0:
|
||||
@@ -2739,6 +2984,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
@@ -2748,6 +2995,10 @@ snapshots:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
brace-expansion@5.0.4:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
buffer@5.7.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
@@ -2835,6 +3086,8 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
de-indent@1.0.2: {}
|
||||
@@ -2954,6 +3207,19 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
natural-compare: 1.4.0
|
||||
nth-check: 2.1.1
|
||||
postcss-selector-parser: 7.1.1
|
||||
semver: 7.7.4
|
||||
vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1))
|
||||
xml-name-validator: 4.0.0
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
|
||||
eslint-scope@8.4.0:
|
||||
dependencies:
|
||||
esrecurse: 4.3.0
|
||||
@@ -2963,6 +3229,8 @@ snapshots:
|
||||
|
||||
eslint-visitor-keys@4.2.1: {}
|
||||
|
||||
eslint-visitor-keys@5.0.1: {}
|
||||
|
||||
eslint@9.39.4(jiti@2.6.1):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||
@@ -3181,6 +3449,8 @@ snapshots:
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@@ -3304,6 +3574,10 @@ snapshots:
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
||||
minimatch@10.2.4:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.4
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
@@ -3350,6 +3624,10 @@ snapshots:
|
||||
|
||||
nostr-wasm@0.1.0: {}
|
||||
|
||||
nth-check@2.1.1:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -3411,6 +3689,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss@8.5.8:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
@@ -3678,6 +3961,10 @@ snapshots:
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
ts-api-utils@2.4.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.21.0:
|
||||
@@ -3798,6 +4085,18 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.30(typescript@5.9.3)
|
||||
|
||||
vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
esquery: 1.7.0
|
||||
semver: 7.7.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
@@ -3838,6 +4137,8 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xml-name-validator@4.0.0: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { webhooksRouter } from './routes/webhooks.js'
|
||||
import { adminRouter } from './routes/admin.js'
|
||||
import { adminProductsRouter } from './routes/adminProducts.js'
|
||||
import { adminOrdersRouter } from './routes/adminOrders.js'
|
||||
import { uploadRouter } from './routes/upload.js'
|
||||
|
||||
const app = express()
|
||||
const port = Number(process.env.PORT) || 3141
|
||||
@@ -30,6 +31,7 @@ app.use('/api/webhooks', webhooksRouter)
|
||||
app.use('/api/admin', adminRouter)
|
||||
app.use('/api/admin/products', adminProductsRouter)
|
||||
app.use('/api/admin/orders', adminOrdersRouter)
|
||||
app.use('/api/admin/upload', uploadRouter)
|
||||
|
||||
app.get('/api/health', (_req, res) => { res.json({ ok: true, timestamp: new Date().toISOString() }) })
|
||||
|
||||
|
||||
114
server/routes/upload.ts
Normal file
114
server/routes/upload.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { adminAuth } from '../middleware/adminAuth.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const UPLOAD_DIR = path.join(__dirname, '..', '..', 'public', 'images')
|
||||
|
||||
export const uploadRouter = Router()
|
||||
uploadRouter.use(adminAuth)
|
||||
|
||||
// Ensure upload directory exists
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif']
|
||||
|
||||
uploadRouter.post('/', async (req, res) => {
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || ''
|
||||
|
||||
if (!contentType.startsWith('multipart/form-data')) {
|
||||
res.status(400).json({ error: { code: 'INVALID_CONTENT_TYPE', message: 'Expected multipart/form-data' } })
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart manually using web-standard approach
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length
|
||||
if (size > MAX_SIZE) {
|
||||
res.status(413).json({ error: { code: 'FILE_TOO_LARGE', message: 'File must be under 5MB' } })
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const body = Buffer.concat(chunks)
|
||||
const boundary = contentType.split('boundary=')[1]
|
||||
if (!boundary) {
|
||||
res.status(400).json({ error: { code: 'NO_BOUNDARY', message: 'Missing multipart boundary' } })
|
||||
return
|
||||
}
|
||||
|
||||
// Find file data between boundaries
|
||||
const boundaryBuf = Buffer.from(`--${boundary}`)
|
||||
const parts = splitBuffer(body, boundaryBuf).filter((p) => p.length > 4)
|
||||
|
||||
const files: string[] = []
|
||||
|
||||
for (const part of parts) {
|
||||
const headerEnd = part.indexOf('\r\n\r\n')
|
||||
if (headerEnd === -1) continue
|
||||
|
||||
const headers = part.subarray(0, headerEnd).toString()
|
||||
const fileData = part.subarray(headerEnd + 4, part.length - 2) // trim trailing \r\n
|
||||
|
||||
const filenameMatch = headers.match(/filename="([^"]+)"/)
|
||||
const typeMatch = headers.match(/Content-Type:\s*(\S+)/i)
|
||||
if (!filenameMatch || !typeMatch) continue
|
||||
|
||||
const mimeType = typeMatch[1]
|
||||
if (!ALLOWED_TYPES.includes(mimeType)) continue
|
||||
|
||||
const ext = path.extname(filenameMatch[1]).toLowerCase() || mimeExtension(mimeType)
|
||||
const filename = `${crypto.randomBytes(16).toString('hex')}${ext}`
|
||||
const filepath = path.join(UPLOAD_DIR, filename)
|
||||
|
||||
fs.writeFileSync(filepath, fileData)
|
||||
files.push(`/images/${filename}`)
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
res.status(400).json({ error: { code: 'NO_FILES', message: 'No valid image files found' } })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({ urls: files })
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
res.status(500).json({ error: { code: 'UPLOAD_FAILED', message: 'File upload failed' } })
|
||||
}
|
||||
})
|
||||
|
||||
function mimeExtension(mime: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/avif': '.avif',
|
||||
}
|
||||
return map[mime] || '.jpg'
|
||||
}
|
||||
|
||||
function splitBuffer(buf: Buffer, delimiter: Buffer): Buffer[] {
|
||||
const parts: Buffer[] = []
|
||||
let start = 0
|
||||
while (true) {
|
||||
const idx = buf.indexOf(delimiter, start)
|
||||
if (idx === -1) {
|
||||
if (start < buf.length) parts.push(buf.subarray(start))
|
||||
break
|
||||
}
|
||||
if (idx > start) parts.push(buf.subarray(start, idx))
|
||||
start = idx + delimiter.length
|
||||
}
|
||||
return parts
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ref, onMounted } from 'vue'
|
||||
const emit = defineEmits<{ complete: [] }>()
|
||||
|
||||
const isAnimating = ref(false)
|
||||
const showTagline = ref(false)
|
||||
const isFading = ref(false)
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
@@ -11,24 +12,29 @@ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)
|
||||
onMounted(() => {
|
||||
if (prefersReducedMotion) {
|
||||
isAnimating.value = true
|
||||
setTimeout(() => emit('complete'), 500)
|
||||
showTagline.value = true
|
||||
setTimeout(() => emit('complete'), 800)
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger animations on next frame
|
||||
requestAnimationFrame(() => {
|
||||
isAnimating.value = true
|
||||
})
|
||||
|
||||
// Total animation: 6 paths staggered at 200ms each, each taking 700ms
|
||||
// Last path starts at 1000ms, finishes at 1700ms, plus 600ms hold
|
||||
// Logo finishes at ~1700ms (6 paths * 200ms stagger + 700ms draw)
|
||||
// Show tagline after logo is fully revealed
|
||||
setTimeout(() => {
|
||||
showTagline.value = true
|
||||
}, 1900)
|
||||
|
||||
// Start fading out after tagline has been visible
|
||||
setTimeout(() => {
|
||||
isFading.value = true
|
||||
}, 2300)
|
||||
}, 3800)
|
||||
|
||||
setTimeout(() => {
|
||||
emit('complete')
|
||||
}, 2700)
|
||||
}, 4200)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -37,6 +43,7 @@ onMounted(() => {
|
||||
class="splash-overlay"
|
||||
:class="{ 'splash-fade-out': isFading }"
|
||||
>
|
||||
<div class="splash-content">
|
||||
<svg
|
||||
width="309"
|
||||
height="121"
|
||||
@@ -45,55 +52,21 @@ onMounted(() => {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="splash-logo"
|
||||
>
|
||||
<!-- Path 1: Main top word -->
|
||||
<path
|
||||
class="logo-path"
|
||||
:class="{ animating: isAnimating }"
|
||||
style="--i: 0"
|
||||
d="M162.632 33.2121C163.232 32.2421 163.772 31.5121 163.912 30.5021L135.492 31.8321L122.392 32.4621L119.402 37.3121L112.622 47.5121L104.132 60.8621L98.9019 69.7521C98.6619 70.1621 98.8519 71.0821 98.9619 71.7221C99.2819 73.6221 96.2519 75.7121 94.4619 74.8021C93.9719 74.5521 93.1219 73.6421 92.6319 73.2221L93.0919 70.5921L95.4019 68.9721C101.212 58.7821 107.562 49.1821 113.992 39.3721C115.932 36.4121 116.642 35.8421 118.382 32.6921L107.602 33.1421C106.832 33.1721 105.662 34.4921 105.092 35.0021L93.2019 45.5121L88.3219 49.9921C83.1919 54.7021 79.0419 60.0821 74.3019 65.2421L69.0919 70.9121C68.6119 71.4321 67.3019 72.0121 66.6919 71.8521C66.0819 71.6921 65.1519 70.8321 64.5619 70.1221C64.1519 69.6221 64.9619 68.1621 65.5919 67.8621C67.6819 66.8721 69.1019 65.5921 70.6019 63.9421L77.3919 56.4521C84.8719 48.2021 93.0619 41.1021 101.802 33.5421L79.5719 34.3221L60.4419 35.1421L35.2419 36.4021C25.4019 36.8921 15.8619 37.5221 6.10188 39.2721C6.37188 39.9121 6.09188 41.1021 5.30188 41.3021C3.98188 41.6521 2.46188 41.7021 1.05188 41.3021C0.231881 41.0621 -0.268119 38.9721 0.151881 38.1721C2.80188 36.7521 14.1019 34.9321 16.9519 34.7521L51.0919 32.5921L61.3219 32.0921L75.6519 31.5021L105.632 30.2321L123.502 14.8521L127.712 11.2321L134.672 5.46214C137.862 2.82214 139.972 -1.68786 142.382 0.652144C142.912 1.17214 143.712 2.67214 143.162 3.25214C141.882 4.61214 140.752 5.73214 139.812 7.12214L135.402 13.6521L127.162 25.7721C126.322 27.0021 125.532 27.8621 125.042 29.2821L135.952 28.7421L157.692 27.8221L165.922 27.5521L170.822 19.0121L175.202 10.9721C175.672 10.1121 177.182 9.56214 177.952 9.70214C178.982 9.89214 180.102 11.4321 179.522 12.5221C179.082 13.3521 177.792 14.3221 177.342 15.0721L175.182 18.6321L170.062 27.3421L178.322 27.0621L230.682 25.7121L237.122 25.6121C238.832 25.5821 240.382 25.6121 242.032 25.5421L254.252 24.9721L268.542 23.8721C275.472 23.3421 280.512 23.1521 287.102 21.2221C288.122 20.9221 289.982 20.8921 290.732 21.3321C291.072 21.5221 291.502 22.5021 291.542 22.8621C291.592 23.3021 290.692 23.9621 290.222 24.0321L278.152 25.7921C269.252 27.0921 260.532 27.6521 251.462 27.9121L210.192 29.0921L180.442 29.9021L168.252 30.3221L153.402 54.2721C151.782 56.8821 150.262 59.0721 148.792 62.0521C151.352 60.3221 155.772 56.1821 158.492 54.4321L170.452 46.7321C171.992 45.7421 172.572 43.6721 175.132 44.9121C175.692 45.1821 176.842 45.6821 176.862 46.2821C176.962 49.4921 174.502 48.2021 168.652 51.8621C156.952 59.1821 154.642 61.9021 144.532 70.4221C143.702 71.1221 141.992 70.6021 141.372 69.9021C139.832 68.1321 142.502 66.4321 143.582 64.4721C145.602 60.8321 147.612 57.5021 149.792 53.9721L162.622 33.2121H162.632ZM111.132 29.9221L120.442 29.7121C124.772 23.9321 128.792 17.9721 132.782 11.4321C129.922 13.5521 127.422 15.7721 124.712 18.0821L118.862 23.0621L116.482 25.1321L111.132 29.9221Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<!-- Path 2: Bottom word -->
|
||||
<path
|
||||
class="logo-path"
|
||||
:class="{ animating: isAnimating }"
|
||||
style="--i: 1"
|
||||
d="M163.772 110.492C155.402 104.552 146.472 99.9124 137.042 95.7124L118.342 87.3824C117.392 86.9624 116.192 86.7024 115.042 86.3124L114.252 83.5724C115.732 82.1024 117.632 83.0524 122.442 82.7624L139.072 81.7524L161.582 80.9524L180.202 80.8524L207.362 80.4624L213.392 75.8924L218.322 71.9624L222.272 68.7724L221.162 67.3924C223.622 61.3724 225.872 55.2924 227.402 48.8524C228.292 45.0824 226.602 44.3824 227.932 43.1124C228.802 42.2824 229.932 42.1124 230.912 42.5124C231.632 42.8124 232.522 44.2224 232.262 45.1124L230.052 52.9224L226.872 62.7824C228.402 62.2824 229.282 61.1424 230.372 60.0024L232.752 57.5224C236.662 53.4324 251.712 39.6824 253.082 39.7424C254.112 39.7824 255.882 41.2324 255.882 42.2424C255.882 43.1924 254.512 44.0924 253.962 44.6024L227.682 68.7924L220.252 75.1724C218.262 76.8824 215.552 78.8524 213.822 80.7224C216.292 80.9124 219.352 80.7224 221.702 80.6224C228.882 80.3024 235.822 80.5424 243.012 80.5924L253.442 80.6624L258.962 80.8524L278.862 81.9924L292.332 83.1824C296.032 83.5124 299.632 84.0424 303.282 83.8024C304.272 83.7324 308.252 83.3024 308.152 85.9424C308.112 86.9524 307.082 88.0524 305.952 87.9424L284.122 85.7724L258.232 84.1324L230.192 83.7324L212.692 83.7024C211.472 83.7024 210.292 84.3924 209.322 85.2124C200.122 92.9624 191.302 100.852 182.932 109.472C180.382 112.092 177.872 114.402 175.572 117.172C174.492 118.482 173.822 120.672 171.402 119.932C171.402 119.932 165.642 111.832 163.762 110.492H163.772ZM165.982 108.462L171.362 112.892C171.762 113.222 172.492 113.752 172.892 113.652C173.292 113.552 174.062 113.122 174.412 112.782L204.192 83.6224L195.722 83.9124L182.092 84.0624L170.632 84.1724L147.362 84.9124L124.272 86.3624C124.812 87.1424 125.632 87.5524 126.522 87.9424L138.402 93.1724L145.712 96.5724C149.592 98.3724 163.172 106.152 165.972 108.462H165.982Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<!-- Path 3 -->
|
||||
<path
|
||||
class="logo-path"
|
||||
:class="{ animating: isAnimating }"
|
||||
style="--i: 2"
|
||||
d="M264.651 59.0018C262.841 60.6418 258.061 64.5218 255.831 63.8618C255.061 63.6318 253.901 62.6418 253.811 61.8118L257.191 56.2218C252.531 60.2818 248.581 64.6118 244.801 69.3518C244.131 70.2018 244.081 71.3618 242.811 71.5118C241.661 71.6518 240.811 70.9318 240.231 69.8718C239.501 68.5218 240.811 68.0418 241.641 67.1318C247.621 60.5618 254.021 54.7518 260.401 48.6018L266.911 42.3318C268.051 41.2318 269.391 42.3618 269.871 43.2718C270.761 44.9318 269.451 45.0818 268.621 46.1618C265.601 50.0518 262.641 53.9718 260.201 58.3318C266.371 53.0918 272.601 49.2318 278.481 44.3418L281.961 41.4418C282.391 41.0818 283.761 41.3618 284.251 41.5718C284.741 41.7818 285.271 43.1818 284.821 43.8118C282.051 47.6618 278.801 52.5018 275.761 57.0218L268.631 67.6118C268.191 68.2618 267.861 69.5318 267.451 70.4018C267.141 71.0618 265.421 71.3418 264.731 71.1018C264.041 70.8618 262.801 69.8118 262.931 69.0118C263.101 67.9018 265.221 65.5918 266.021 64.7718C269.381 61.3018 273.481 54.5818 276.161 50.0618C274.711 50.7018 273.891 51.3718 272.801 52.2418C270.021 54.4718 267.311 56.6218 264.671 59.0218L264.651 59.0018Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<!-- Path 4 -->
|
||||
<path
|
||||
class="logo-path"
|
||||
:class="{ animating: isAnimating }"
|
||||
style="--i: 3"
|
||||
d="M209.662 67.2223C209.172 68.3723 208.772 69.6523 208.032 69.9323C207.012 70.3123 205.152 69.8823 204.842 69.0623C204.152 67.2423 205.622 66.3423 206.382 65.1223C208.662 61.4523 210.792 57.9623 212.652 54.0223L204.742 60.7123C200.482 64.3123 197.082 68.6323 193.462 72.9023C192.822 73.6523 191.642 74.1523 190.952 73.9323C190.032 73.6323 188.702 72.4323 189.222 71.6023C190.542 69.4823 191.642 67.6823 192.762 65.6123L198.762 54.5823L205.352 41.6423C205.702 40.9623 207.722 40.8323 208.402 41.1823C209.122 41.5523 209.982 42.8323 210.222 43.7423C208.302 44.5423 207.522 45.5123 206.672 47.0823L203.622 52.7123L198.892 61.2723C200.202 60.3423 201.302 59.3723 202.602 58.2723L210.842 51.2523L216.502 46.2123C217.412 45.4023 217.462 43.8323 219.022 43.9523C220.002 44.0223 221.962 45.1123 221.152 46.5223L214.312 58.4923C212.682 61.3523 211.012 64.0723 209.662 67.2623V67.2223Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<!-- Path 5 -->
|
||||
<path
|
||||
class="logo-path"
|
||||
:class="{ animating: isAnimating }"
|
||||
style="--i: 4"
|
||||
d="M120.041 66.0921C117.531 68.3021 111.991 74.6621 108.871 72.9321C108.351 72.6421 107.691 71.3221 108.041 70.8321L112.331 64.8221C115.871 59.8621 119.131 54.8321 121.681 49.2721C122.051 48.4721 122.331 47.5821 122.841 47.1021C123.431 46.5521 124.801 46.4221 125.351 47.0021C125.901 47.5821 126.431 48.3021 126.561 48.7921C126.691 49.2821 125.741 50.1221 125.471 50.5621C123.221 54.2521 121.191 57.9321 118.611 61.4021C117.751 62.5621 117.001 63.6221 116.431 64.9021L125.361 57.0921L132.551 51.6021L138.361 46.9521C139.331 46.1821 141.691 48.4621 141.121 49.4321C139.251 51.9821 137.581 54.3121 136.021 56.9421L133.511 61.1721L129.901 67.8321C129.421 68.7121 127.711 69.2521 127.151 68.5421L125.041 65.8421C127.761 63.5021 129.731 60.8521 131.371 57.7821L132.571 55.5521L126.361 60.5221L120.041 66.0821V66.0921Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<!-- Path 6 -->
|
||||
<path
|
||||
class="logo-path"
|
||||
:class="{ animating: isAnimating }"
|
||||
style="--i: 5"
|
||||
d="M189.921 51.7524C184.441 56.3024 180.001 61.3624 175.351 66.5424L172.661 69.5424C172.151 70.1124 171.031 70.6424 170.181 70.5524C169.551 70.4924 168.661 69.9524 168.321 69.5124C167.981 69.0724 168.111 67.9324 168.491 67.3624L175.621 56.7024L179.141 51.9724C181.891 48.2824 184.051 44.5524 186.211 40.3724C187.151 39.7624 188.831 40.6424 189.881 41.3024C190.281 41.5524 194.551 41.6324 196.871 43.1824L197.911 45.7624C197.311 47.0624 195.961 47.2324 194.921 47.7924C193.161 48.7324 191.621 50.3524 189.911 51.7724L189.921 51.7524ZM182.321 54.0124L190.771 46.3624C190.281 45.3424 188.511 45.4324 187.761 45.8424C187.201 46.1424 186.671 47.0824 186.161 47.8124L181.181 54.8824C181.911 55.3924 181.951 54.3624 182.321 54.0224V54.0124Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path class="logo-path" :class="{ animating: isAnimating }" style="--i: 0" d="M162.632 33.2121C163.232 32.2421 163.772 31.5121 163.912 30.5021L135.492 31.8321L122.392 32.4621L119.402 37.3121L112.622 47.5121L104.132 60.8621L98.9019 69.7521C98.6619 70.1621 98.8519 71.0821 98.9619 71.7221C99.2819 73.6221 96.2519 75.7121 94.4619 74.8021C93.9719 74.5521 93.1219 73.6421 92.6319 73.2221L93.0919 70.5921L95.4019 68.9721C101.212 58.7821 107.562 49.1821 113.992 39.3721C115.932 36.4121 116.642 35.8421 118.382 32.6921L107.602 33.1421C106.832 33.1721 105.662 34.4921 105.092 35.0021L93.2019 45.5121L88.3219 49.9921C83.1919 54.7021 79.0419 60.0821 74.3019 65.2421L69.0919 70.9121C68.6119 71.4321 67.3019 72.0121 66.6919 71.8521C66.0819 71.6921 65.1519 70.8321 64.5619 70.1221C64.1519 69.6221 64.9619 68.1621 65.5919 67.8621C67.6819 66.8721 69.1019 65.5921 70.6019 63.9421L77.3919 56.4521C84.8719 48.2021 93.0619 41.1021 101.802 33.5421L79.5719 34.3221L60.4419 35.1421L35.2419 36.4021C25.4019 36.8921 15.8619 37.5221 6.10188 39.2721C6.37188 39.9121 6.09188 41.1021 5.30188 41.3021C3.98188 41.6521 2.46188 41.7021 1.05188 41.3021C0.231881 41.0621 -0.268119 38.9721 0.151881 38.1721C2.80188 36.7521 14.1019 34.9321 16.9519 34.7521L51.0919 32.5921L61.3219 32.0921L75.6519 31.5021L105.632 30.2321L123.502 14.8521L127.712 11.2321L134.672 5.46214C137.862 2.82214 139.972 -1.68786 142.382 0.652144C142.912 1.17214 143.712 2.67214 143.162 3.25214C141.882 4.61214 140.752 5.73214 139.812 7.12214L135.402 13.6521L127.162 25.7721C126.322 27.0021 125.532 27.8621 125.042 29.2821L135.952 28.7421L157.692 27.8221L165.922 27.5521L170.822 19.0121L175.202 10.9721C175.672 10.1121 177.182 9.56214 177.952 9.70214C178.982 9.89214 180.102 11.4321 179.522 12.5221C179.082 13.3521 177.792 14.3221 177.342 15.0721L175.182 18.6321L170.062 27.3421L178.322 27.0621L230.682 25.7121L237.122 25.6121C238.832 25.5821 240.382 25.6121 242.032 25.5421L254.252 24.9721L268.542 23.8721C275.472 23.3421 280.512 23.1521 287.102 21.2221C288.122 20.9221 289.982 20.8921 290.732 21.3321C291.072 21.5221 291.502 22.5021 291.542 22.8621C291.592 23.3021 290.692 23.9621 290.222 24.0321L278.152 25.7921C269.252 27.0921 260.532 27.6521 251.462 27.9121L210.192 29.0921L180.442 29.9021L168.252 30.3221L153.402 54.2721C151.782 56.8821 150.262 59.0721 148.792 62.0521C151.352 60.3221 155.772 56.1821 158.492 54.4321L170.452 46.7321C171.992 45.7421 172.572 43.6721 175.132 44.9121C175.692 45.1821 176.842 45.6821 176.862 46.2821C176.962 49.4921 174.502 48.2021 168.652 51.8621C156.952 59.1821 154.642 61.9021 144.532 70.4221C143.702 71.1221 141.992 70.6021 141.372 69.9021C139.832 68.1321 142.502 66.4321 143.582 64.4721C145.602 60.8321 147.612 57.5021 149.792 53.9721L162.622 33.2121H162.632ZM111.132 29.9221L120.442 29.7121C124.772 23.9321 128.792 17.9721 132.782 11.4321C129.922 13.5521 127.422 15.7721 124.712 18.0821L118.862 23.0621L116.482 25.1321L111.132 29.9221Z" fill="currentColor" />
|
||||
<path class="logo-path" :class="{ animating: isAnimating }" style="--i: 1" d="M163.772 110.492C155.402 104.552 146.472 99.9124 137.042 95.7124L118.342 87.3824C117.392 86.9624 116.192 86.7024 115.042 86.3124L114.252 83.5724C115.732 82.1024 117.632 83.0524 122.442 82.7624L139.072 81.7524L161.582 80.9524L180.202 80.8524L207.362 80.4624L213.392 75.8924L218.322 71.9624L222.272 68.7724L221.162 67.3924C223.622 61.3724 225.872 55.2924 227.402 48.8524C228.292 45.0824 226.602 44.3824 227.932 43.1124C228.802 42.2824 229.932 42.1124 230.912 42.5124C231.632 42.8124 232.522 44.2224 232.262 45.1124L230.052 52.9224L226.872 62.7824C228.402 62.2824 229.282 61.1424 230.372 60.0024L232.752 57.5224C236.662 53.4324 251.712 39.6824 253.082 39.7424C254.112 39.7824 255.882 41.2324 255.882 42.2424C255.882 43.1924 254.512 44.0924 253.962 44.6024L227.682 68.7924L220.252 75.1724C218.262 76.8824 215.552 78.8524 213.822 80.7224C216.292 80.9124 219.352 80.7224 221.702 80.6224C228.882 80.3024 235.822 80.5424 243.012 80.5924L253.442 80.6624L258.962 80.8524L278.862 81.9924L292.332 83.1824C296.032 83.5124 299.632 84.0424 303.282 83.8024C304.272 83.7324 308.252 83.3024 308.152 85.9424C308.112 86.9524 307.082 88.0524 305.952 87.9424L284.122 85.7724L258.232 84.1324L230.192 83.7324L212.692 83.7024C211.472 83.7024 210.292 84.3924 209.322 85.2124C200.122 92.9624 191.302 100.852 182.932 109.472C180.382 112.092 177.872 114.402 175.572 117.172C174.492 118.482 173.822 120.672 171.402 119.932C171.402 119.932 165.642 111.832 163.762 110.492H163.772ZM165.982 108.462L171.362 112.892C171.762 113.222 172.492 113.752 172.892 113.652C173.292 113.552 174.062 113.122 174.412 112.782L204.192 83.6224L195.722 83.9124L182.092 84.0624L170.632 84.1724L147.362 84.9124L124.272 86.3624C124.812 87.1424 125.632 87.5524 126.522 87.9424L138.402 93.1724L145.712 96.5724C149.592 98.3724 163.172 106.152 165.972 108.462H165.982Z" fill="currentColor" />
|
||||
<path class="logo-path" :class="{ animating: isAnimating }" style="--i: 2" d="M264.651 59.0018C262.841 60.6418 258.061 64.5218 255.831 63.8618C255.061 63.6318 253.901 62.6418 253.811 61.8118L257.191 56.2218C252.531 60.2818 248.581 64.6118 244.801 69.3518C244.131 70.2018 244.081 71.3618 242.811 71.5118C241.661 71.6518 240.811 70.9318 240.231 69.8718C239.501 68.5218 240.811 68.0418 241.641 67.1318C247.621 60.5618 254.021 54.7518 260.401 48.6018L266.911 42.3318C268.051 41.2318 269.391 42.3618 269.871 43.2718C270.761 44.9318 269.451 45.0818 268.621 46.1618C265.601 50.0518 262.641 53.9718 260.201 58.3318C266.371 53.0918 272.601 49.2318 278.481 44.3418L281.961 41.4418C282.391 41.0818 283.761 41.3618 284.251 41.5718C284.741 41.7818 285.271 43.1818 284.821 43.8118C282.051 47.6618 278.801 52.5018 275.761 57.0218L268.631 67.6118C268.191 68.2618 267.861 69.5318 267.451 70.4018C267.141 71.0618 265.421 71.3418 264.731 71.1018C264.041 70.8618 262.801 69.8118 262.931 69.0118C263.101 67.9018 265.221 65.5918 266.021 64.7718C269.381 61.3018 273.481 54.5818 276.161 50.0618C274.711 50.7018 273.891 51.3718 272.801 52.2418C270.021 54.4718 267.311 56.6218 264.671 59.0218L264.651 59.0018Z" fill="currentColor" />
|
||||
<path class="logo-path" :class="{ animating: isAnimating }" style="--i: 3" d="M209.662 67.2223C209.172 68.3723 208.772 69.6523 208.032 69.9323C207.012 70.3123 205.152 69.8823 204.842 69.0623C204.152 67.2423 205.622 66.3423 206.382 65.1223C208.662 61.4523 210.792 57.9623 212.652 54.0223L204.742 60.7123C200.482 64.3123 197.082 68.6323 193.462 72.9023C192.822 73.6523 191.642 74.1523 190.952 73.9323C190.032 73.6323 188.702 72.4323 189.222 71.6023C190.542 69.4823 191.642 67.6823 192.762 65.6123L198.762 54.5823L205.352 41.6423C205.702 40.9623 207.722 40.8323 208.402 41.1823C209.122 41.5523 209.982 42.8323 210.222 43.7423C208.302 44.5423 207.522 45.5123 206.672 47.0823L203.622 52.7123L198.892 61.2723C200.202 60.3423 201.302 59.3723 202.602 58.2723L210.842 51.2523L216.502 46.2123C217.412 45.4023 217.462 43.8323 219.022 43.9523C220.002 44.0223 221.962 45.1123 221.152 46.5223L214.312 58.4923C212.682 61.3523 211.012 64.0723 209.662 67.2623V67.2223Z" fill="currentColor" />
|
||||
<path class="logo-path" :class="{ animating: isAnimating }" style="--i: 4" d="M120.041 66.0921C117.531 68.3021 111.991 74.6621 108.871 72.9321C108.351 72.6421 107.691 71.3221 108.041 70.8321L112.331 64.8221C115.871 59.8621 119.131 54.8321 121.681 49.2721C122.051 48.4721 122.331 47.5821 122.841 47.1021C123.431 46.5521 124.801 46.4221 125.351 47.0021C125.901 47.5821 126.431 48.3021 126.561 48.7921C126.691 49.2821 125.741 50.1221 125.471 50.5621C123.221 54.2521 121.191 57.9321 118.611 61.4021C117.751 62.5621 117.001 63.6221 116.431 64.9021L125.361 57.0921L132.551 51.6021L138.361 46.9521C139.331 46.1821 141.691 48.4621 141.121 49.4321C139.251 51.9821 137.581 54.3121 136.021 56.9421L133.511 61.1721L129.901 67.8321C129.421 68.7121 127.711 69.2521 127.151 68.5421L125.041 65.8421C127.761 63.5021 129.731 60.8521 131.371 57.7821L132.571 55.5521L126.361 60.5221L120.041 66.0821V66.0921Z" fill="currentColor" />
|
||||
<path class="logo-path" :class="{ animating: isAnimating }" style="--i: 5" d="M189.921 51.7524C184.441 56.3024 180.001 61.3624 175.351 66.5424L172.661 69.5424C172.151 70.1124 171.031 70.6424 170.181 70.5524C169.551 70.4924 168.661 69.9524 168.321 69.5124C167.981 69.0724 168.111 67.9324 168.491 67.3624L175.621 56.7024L179.141 51.9724C181.891 48.2824 184.051 44.5524 186.211 40.3724C187.151 39.7624 188.831 40.6424 189.881 41.3024C190.281 41.5524 194.551 41.6324 196.871 43.1824L197.911 45.7624C197.311 47.0624 195.961 47.2324 194.921 47.7924C193.161 48.7324 191.621 50.3524 189.911 51.7724L189.921 51.7524ZM182.321 54.0124L190.771 46.3624C190.281 45.3424 188.511 45.4324 187.761 45.8424C187.201 46.1424 186.671 47.0824 186.161 47.8124L181.181 54.8824C181.911 55.3924 181.951 54.3624 182.321 54.0224V54.0124Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
<h2
|
||||
class="tagline"
|
||||
:class="{ visible: showTagline }"
|
||||
>
|
||||
EVERYTHING YOU LOVE IS A PSYOP
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -114,6 +87,13 @@ onMounted(() => {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.splash-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.splash-logo {
|
||||
width: min(70vw, 400px);
|
||||
height: auto;
|
||||
@@ -133,21 +113,31 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
@keyframes revealPath {
|
||||
from {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
from { clip-path: inset(0 100% 0 0); }
|
||||
to { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
@keyframes fadeInPath {
|
||||
from {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-family: system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||
font-size: clamp(0.75rem, 2.5vw, 1.125rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transition: opacity 600ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
to {
|
||||
|
||||
.tagline.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -156,6 +146,12 @@ onMounted(() => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.splash-overlay {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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>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">
|
||||
<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; }
|
||||
|
||||
38
tests/server/btcpay.test.ts
Normal file
38
tests/server/btcpay.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import crypto from 'node:crypto'
|
||||
import { validateWebhookSignature } from '../../server/lib/btcpay.js'
|
||||
|
||||
describe('BTCPay webhook validation', () => {
|
||||
const secret = 'test-webhook-secret-key'
|
||||
|
||||
it('validates correct HMAC signature', () => {
|
||||
process.env.BTCPAY_WEBHOOK_SECRET = secret
|
||||
const body = '{"type":"InvoiceSettled","invoiceId":"test123"}'
|
||||
const hmac = crypto.createHmac('sha256', secret).update(body).digest('hex')
|
||||
|
||||
expect(validateWebhookSignature(body, `sha256=${hmac}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects incorrect signature', () => {
|
||||
process.env.BTCPAY_WEBHOOK_SECRET = secret
|
||||
const body = '{"type":"InvoiceSettled","invoiceId":"test123"}'
|
||||
|
||||
expect(validateWebhookSignature(body, 'sha256=deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when no secret configured', () => {
|
||||
delete process.env.BTCPAY_WEBHOOK_SECRET
|
||||
const body = '{"type":"InvoiceSettled"}'
|
||||
|
||||
expect(validateWebhookSignature(body, 'sha256=anything')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects tampered body', () => {
|
||||
process.env.BTCPAY_WEBHOOK_SECRET = secret
|
||||
const body = '{"type":"InvoiceSettled","invoiceId":"test123"}'
|
||||
const hmac = crypto.createHmac('sha256', secret).update(body).digest('hex')
|
||||
|
||||
const tamperedBody = '{"type":"InvoiceSettled","invoiceId":"hacked"}'
|
||||
expect(validateWebhookSignature(tamperedBody, `sha256=${hmac}`)).toBe(false)
|
||||
})
|
||||
})
|
||||
55
tests/server/crypto.test.ts
Normal file
55
tests/server/crypto.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
// Generate a test key and set it before importing the module
|
||||
const testKey = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
describe('server/lib/crypto', () => {
|
||||
beforeAll(() => {
|
||||
process.env.ENCRYPTION_KEY = testKey
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.ENCRYPTION_KEY
|
||||
})
|
||||
|
||||
it('encrypts and decrypts shipping address', async () => {
|
||||
const { encrypt, decrypt } = await import('../../server/lib/crypto.js')
|
||||
|
||||
const address = JSON.stringify({
|
||||
name: 'Satoshi Nakamoto',
|
||||
line1: '1 Bitcoin Ave',
|
||||
city: 'Cryptoville',
|
||||
postalCode: '21000',
|
||||
country: 'Decentraland',
|
||||
})
|
||||
|
||||
const encrypted = encrypt(address)
|
||||
expect(encrypted).not.toBe(address)
|
||||
expect(encrypted).toMatch(/^[0-9a-f]+$/)
|
||||
|
||||
const decrypted = decrypt(encrypted)
|
||||
expect(decrypted).toBe(address)
|
||||
expect(JSON.parse(decrypted).name).toBe('Satoshi Nakamoto')
|
||||
})
|
||||
|
||||
it('produces different ciphertext for same plaintext', async () => {
|
||||
const { encrypt } = await import('../../server/lib/crypto.js')
|
||||
|
||||
const plaintext = 'same input'
|
||||
const a = encrypt(plaintext)
|
||||
const b = encrypt(plaintext)
|
||||
|
||||
expect(a).not.toBe(b) // Random nonce ensures different output
|
||||
})
|
||||
|
||||
it('fails on tampered ciphertext', async () => {
|
||||
const { encrypt, decrypt } = await import('../../server/lib/crypto.js')
|
||||
|
||||
const encrypted = encrypt('secret data')
|
||||
// Flip a byte in the middle
|
||||
const tampered = encrypted.slice(0, 40) + 'ff' + encrypted.slice(42)
|
||||
|
||||
expect(() => decrypt(tampered)).toThrow()
|
||||
})
|
||||
})
|
||||
47
tests/server/validate.test.ts
Normal file
47
tests/server/validate.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sanitizeString, sanitizeInt } from '../../server/middleware/validate.js'
|
||||
|
||||
describe('sanitizeString', () => {
|
||||
it('trims whitespace', () => {
|
||||
expect(sanitizeString(' hello ')).toBe('hello')
|
||||
})
|
||||
|
||||
it('truncates to 10000 chars', () => {
|
||||
const long = 'a'.repeat(20_000)
|
||||
expect(sanitizeString(long)).toHaveLength(10_000)
|
||||
})
|
||||
|
||||
it('returns empty string for non-string input', () => {
|
||||
expect(sanitizeString(123)).toBe('')
|
||||
expect(sanitizeString(null)).toBe('')
|
||||
expect(sanitizeString(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeInt', () => {
|
||||
it('accepts valid positive integers', () => {
|
||||
expect(sanitizeInt(42)).toBe(42)
|
||||
expect(sanitizeInt(0)).toBe(0)
|
||||
expect(sanitizeInt(100_000)).toBe(100_000)
|
||||
})
|
||||
|
||||
it('rejects negative numbers', () => {
|
||||
expect(sanitizeInt(-1)).toBeNull()
|
||||
expect(sanitizeInt(-100)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects floats', () => {
|
||||
expect(sanitizeInt(1.5)).toBeNull()
|
||||
expect(sanitizeInt(0.001)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects non-numeric input', () => {
|
||||
expect(sanitizeInt('abc')).toBeNull()
|
||||
expect(sanitizeInt(NaN)).toBeNull()
|
||||
})
|
||||
|
||||
it('converts string numbers', () => {
|
||||
expect(sanitizeInt('42')).toBe(42)
|
||||
expect(sanitizeInt('0')).toBe(0)
|
||||
})
|
||||
})
|
||||
52
tests/shared/types.test.ts
Normal file
52
tests/shared/types.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { Product, Order, CartItem, OrderStatus, SizeStock } from '@shared/types'
|
||||
|
||||
describe('shared types', () => {
|
||||
it('Product type enforces integer sats', () => {
|
||||
const product: Product = {
|
||||
id: 'test-1',
|
||||
name: 'Test Product',
|
||||
slug: 'test-product',
|
||||
description: 'A test product',
|
||||
priceSats: 100_000,
|
||||
images: ['/images/test.jpg'],
|
||||
sizes: [{ size: 'M', stock: 10 }],
|
||||
category: 'tops',
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
expect(product.priceSats).toBe(100_000)
|
||||
expect(Number.isInteger(product.priceSats)).toBe(true)
|
||||
})
|
||||
|
||||
it('CartItem calculates correctly with integer sats', () => {
|
||||
const item: CartItem = {
|
||||
productId: 'test-1',
|
||||
slug: 'test-product',
|
||||
name: 'Test Product',
|
||||
size: 'M',
|
||||
quantity: 3,
|
||||
priceSats: 85_000,
|
||||
image: '/images/test.jpg',
|
||||
}
|
||||
const total = item.priceSats * item.quantity
|
||||
expect(total).toBe(255_000)
|
||||
expect(Number.isInteger(total)).toBe(true)
|
||||
})
|
||||
|
||||
it('OrderStatus has valid values', () => {
|
||||
const statuses: OrderStatus[] = ['pending', 'paid', 'confirmed', 'shipped', 'delivered', 'cancelled']
|
||||
expect(statuses).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('SizeStock tracks integer stock levels', () => {
|
||||
const sizes: SizeStock[] = [
|
||||
{ size: 'S', stock: 10 },
|
||||
{ size: 'M', stock: 0 },
|
||||
]
|
||||
const totalStock = sizes.reduce((sum, s) => sum + s.stock, 0)
|
||||
expect(totalStock).toBe(10)
|
||||
expect(sizes[1].stock).toBe(0)
|
||||
})
|
||||
})
|
||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@shared': fileURLToPath(new URL('./shared', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user