wiki-plugin-shoppe 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +125 -0
- package/client/client.js +207 -0
- package/factory.json +5 -0
- package/index.js +1 -0
- package/package.json +27 -0
- package/server/server.js +625 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# wiki-plugin-shoppe — Developer Documentation
|
|
2
|
+
|
|
3
|
+
Multi-tenant digital goods shoppe for Federated Wiki, powered by Sanora.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
Follows the Service-Bundling Plugin Pattern. Each tenant (seller/creator) gets their own Sanora user account, identified by a UUID and an 8-emoji emojicode (same format as BDO: 3 base + 5 unique from the EMOJI_PALETTE).
|
|
8
|
+
|
|
9
|
+
### Tenant Identity
|
|
10
|
+
|
|
11
|
+
Each tenant has:
|
|
12
|
+
- **UUID** — their Sanora user UUID
|
|
13
|
+
- **Emojicode** — 8-emoji human-readable identifier (e.g. `🛍️🎨🎁🌟💎🐉📚🔥`)
|
|
14
|
+
|
|
15
|
+
The first 3 emoji are fixed per wiki instance (`SHOPPE_BASE_EMOJI`, default `🛍️🎨🎁`). The last 5 are unique per tenant.
|
|
16
|
+
|
|
17
|
+
### Tenant Lifecycle
|
|
18
|
+
|
|
19
|
+
1. Wiki owner registers a tenant: `POST /plugin/shoppe/register { name }`
|
|
20
|
+
- Plugin creates a Sanora user for the tenant
|
|
21
|
+
- Generates emojicode, stores `{ uuid, emojicode, name, keys }` in `.shoppe-tenants.json`
|
|
22
|
+
- Returns `{ uuid, emojicode }` — tenant puts these in their `manifest.json`
|
|
23
|
+
2. Tenant builds their archive (see format below)
|
|
24
|
+
3. Tenant drags archive onto the wiki plugin widget
|
|
25
|
+
4. Plugin verifies `uuid + emojicode`, processes all goods into Sanora
|
|
26
|
+
5. Shoppe accessible at `/plugin/shoppe/:uuid` or `/plugin/shoppe/:emojicode`
|
|
27
|
+
|
|
28
|
+
## Archive Format
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
my-shoppe.zip
|
|
32
|
+
manifest.json ← required: { uuid, emojicode, name }
|
|
33
|
+
books/
|
|
34
|
+
My Novel.epub
|
|
35
|
+
Technical Guide.pdf
|
|
36
|
+
music/
|
|
37
|
+
My Album/ ← album = subfolder
|
|
38
|
+
cover.jpg
|
|
39
|
+
01-track.mp3
|
|
40
|
+
02-track.mp3
|
|
41
|
+
Standalone Track.mp3 ← standalone track = file directly in music/
|
|
42
|
+
posts/
|
|
43
|
+
2025-01-hello-world.md
|
|
44
|
+
2025-02-another-post.md
|
|
45
|
+
albums/
|
|
46
|
+
Vacation 2025/ ← photo album = subfolder
|
|
47
|
+
photo1.jpg
|
|
48
|
+
photo2.jpg
|
|
49
|
+
products/
|
|
50
|
+
T-Shirt/ ← product = subfolder with cover + info.json
|
|
51
|
+
cover.jpg
|
|
52
|
+
info.json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### manifest.json
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"uuid": "your-uuid-from-registration",
|
|
60
|
+
"emojicode": "🛍️🎨🎁🌟💎🐉📚🔥",
|
|
61
|
+
"name": "My Shoppe"
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### products/*/info.json
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"title": "Planet Nine T-Shirt",
|
|
70
|
+
"description": "Comfortable cotton tee with logo",
|
|
71
|
+
"price": 25,
|
|
72
|
+
"shipping": 5
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Routes
|
|
77
|
+
|
|
78
|
+
| Method | Path | Auth | Description |
|
|
79
|
+
|--------|------|------|-------------|
|
|
80
|
+
| `POST` | `/plugin/shoppe/register` | Owner | Register new tenant |
|
|
81
|
+
| `GET` | `/plugin/shoppe/tenants` | Owner | List all tenants |
|
|
82
|
+
| `POST` | `/plugin/shoppe/upload` | UUID+emojicode in archive | Upload goods archive |
|
|
83
|
+
| `GET` | `/plugin/shoppe/:id` | Public | Shoppe HTML page |
|
|
84
|
+
| `GET` | `/plugin/shoppe/:id/goods` | Public | Goods JSON |
|
|
85
|
+
| `GET` | `/plugin/shoppe/:id/goods?category=books` | Public | Filtered goods JSON |
|
|
86
|
+
|
|
87
|
+
`:id` accepts either UUID or emojicode.
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Base emoji for all tenant emojicodes on this wiki (default: 🛍️🎨🎁)
|
|
93
|
+
export SHOPPE_BASE_EMOJI="🏪🎪🎁"
|
|
94
|
+
|
|
95
|
+
# Sanora port (default: 7243)
|
|
96
|
+
export SANORA_PORT=7243
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Supported File Types
|
|
100
|
+
|
|
101
|
+
| Category | Extensions |
|
|
102
|
+
|----------|-----------|
|
|
103
|
+
| Books | .epub, .pdf, .mobi, .azw, .azw3 |
|
|
104
|
+
| Music | .mp3, .flac, .m4a, .ogg, .wav |
|
|
105
|
+
| Posts | .md |
|
|
106
|
+
| Albums | .jpg, .jpeg, .png, .gif, .webp, .svg |
|
|
107
|
+
| Products | .jpg/.png cover + info.json |
|
|
108
|
+
|
|
109
|
+
## Storage
|
|
110
|
+
|
|
111
|
+
Tenant registry: `.shoppe-tenants.json` (gitignored — contains private keys)
|
|
112
|
+
|
|
113
|
+
Each tenant's goods are stored in Sanora under their own UUID.
|
|
114
|
+
|
|
115
|
+
## Dependencies
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"adm-zip": "^0.5.10",
|
|
120
|
+
"form-data": "^4.0.0",
|
|
121
|
+
"multer": "^1.4.5-lts.1",
|
|
122
|
+
"node-fetch": "^2.6.1",
|
|
123
|
+
"sessionless-node": "^0.9.12"
|
|
124
|
+
}
|
|
125
|
+
```
|
package/client/client.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
|
|
3
|
+
window.plugins.shoppe = {
|
|
4
|
+
emit: function($item, item) {
|
|
5
|
+
const div = $item[0];
|
|
6
|
+
div.innerHTML = `
|
|
7
|
+
<div class="shoppe-widget">
|
|
8
|
+
<style>
|
|
9
|
+
.shoppe-widget { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 700px; margin: 0 auto; }
|
|
10
|
+
.shoppe-drop { border: 2px dashed #ccc; border-radius: 14px; padding: 40px 24px; text-align: center; cursor: pointer; transition: border-color 0.2s, background 0.2s; background: #fafafa; }
|
|
11
|
+
.shoppe-drop.dragover { border-color: #0066cc; background: #e8f0fe; }
|
|
12
|
+
.shoppe-drop-icon { font-size: 48px; margin-bottom: 12px; }
|
|
13
|
+
.shoppe-drop h3 { font-size: 18px; font-weight: 600; margin-bottom: 6px; color: #1d1d1f; }
|
|
14
|
+
.shoppe-drop p { font-size: 13px; color: #888; margin-bottom: 16px; }
|
|
15
|
+
.shoppe-btn { display: inline-block; padding: 10px 22px; border-radius: 20px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: background 0.15s; }
|
|
16
|
+
.shoppe-btn-blue { background: #0066cc; color: white; }
|
|
17
|
+
.shoppe-btn-blue:hover { background: #0055aa; }
|
|
18
|
+
.shoppe-btn-green { background: #10b981; color: white; }
|
|
19
|
+
.shoppe-btn-green:hover { background: #059669; }
|
|
20
|
+
.shoppe-section { margin-top: 20px; }
|
|
21
|
+
.shoppe-section h4 { font-size: 13px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
|
|
22
|
+
.shoppe-tenant { display: flex; align-items: center; justify-content: space-between; background: white; border: 1px solid #eee; border-radius: 10px; padding: 12px 16px; margin-bottom: 8px; }
|
|
23
|
+
.shoppe-tenant-info { display: flex; flex-direction: column; gap: 2px; }
|
|
24
|
+
.shoppe-tenant-name { font-weight: 600; font-size: 14px; }
|
|
25
|
+
.shoppe-tenant-code { font-size: 16px; letter-spacing: 3px; }
|
|
26
|
+
.shoppe-tenant-link { font-size: 13px; color: #0066cc; text-decoration: none; }
|
|
27
|
+
.shoppe-tenant-link:hover { text-decoration: underline; }
|
|
28
|
+
.shoppe-status { margin-top: 14px; border-radius: 10px; padding: 14px 18px; font-size: 14px; }
|
|
29
|
+
.shoppe-status.info { background: #e8f0fe; color: #1a56db; }
|
|
30
|
+
.shoppe-status.success { background: #d1fae5; color: #065f46; }
|
|
31
|
+
.shoppe-status.error { background: #fee2e2; color: #991b1b; }
|
|
32
|
+
.shoppe-register { display: flex; gap: 8px; margin-top: 12px; }
|
|
33
|
+
.shoppe-register input { flex: 1; padding: 9px 14px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
|
|
34
|
+
.shoppe-result-counts { margin-top: 8px; font-size: 13px; opacity: 0.8; }
|
|
35
|
+
</style>
|
|
36
|
+
|
|
37
|
+
<!-- Drop zone -->
|
|
38
|
+
<div class="shoppe-drop" id="shoppe-drop">
|
|
39
|
+
<div class="shoppe-drop-icon">🛍️</div>
|
|
40
|
+
<h3>Drop your shoppe archive here</h3>
|
|
41
|
+
<p>Drag and drop a .zip archive, or click to browse.<br>Archive must contain manifest.json with your uuid and emojicode.</p>
|
|
42
|
+
<button class="shoppe-btn shoppe-btn-blue" id="shoppe-browse-btn">Choose Archive</button>
|
|
43
|
+
<input type="file" id="shoppe-file-input" accept=".zip" style="display:none">
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<!-- Register section -->
|
|
47
|
+
<div class="shoppe-section">
|
|
48
|
+
<h4>Register New Shoppe</h4>
|
|
49
|
+
<div class="shoppe-register">
|
|
50
|
+
<input type="text" id="shoppe-name-input" placeholder="Shoppe name (e.g. Zach's Art Store)">
|
|
51
|
+
<button class="shoppe-btn shoppe-btn-green" id="shoppe-register-btn">Register</button>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<!-- Tenant list -->
|
|
56
|
+
<div class="shoppe-section" id="shoppe-tenants-section">
|
|
57
|
+
<h4>Active Shoppes</h4>
|
|
58
|
+
<div id="shoppe-tenants-list"><em style="font-size:13px;color:#999">Loading...</em></div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<!-- Status -->
|
|
62
|
+
<div id="shoppe-status" style="display:none"></div>
|
|
63
|
+
</div>
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
setupShoppeListeners(div);
|
|
67
|
+
loadTenants(div);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
bind: function($item, item) {}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function setupShoppeListeners(container) {
|
|
74
|
+
const drop = container.querySelector('#shoppe-drop');
|
|
75
|
+
const fileInput = container.querySelector('#shoppe-file-input');
|
|
76
|
+
const browseBtn = container.querySelector('#shoppe-browse-btn');
|
|
77
|
+
const registerBtn = container.querySelector('#shoppe-register-btn');
|
|
78
|
+
const nameInput = container.querySelector('#shoppe-name-input');
|
|
79
|
+
|
|
80
|
+
// Browse button
|
|
81
|
+
browseBtn.addEventListener('click', () => fileInput.click());
|
|
82
|
+
|
|
83
|
+
// File selected via browser
|
|
84
|
+
fileInput.addEventListener('change', e => {
|
|
85
|
+
const file = e.target.files[0];
|
|
86
|
+
if (file) uploadArchive(file, container);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Drag and drop
|
|
90
|
+
drop.addEventListener('dragover', e => {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
drop.classList.add('dragover');
|
|
93
|
+
});
|
|
94
|
+
drop.addEventListener('dragleave', () => drop.classList.remove('dragover'));
|
|
95
|
+
drop.addEventListener('drop', e => {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
drop.classList.remove('dragover');
|
|
98
|
+
const file = e.dataTransfer.files[0];
|
|
99
|
+
if (file && file.name.endsWith('.zip')) {
|
|
100
|
+
uploadArchive(file, container);
|
|
101
|
+
} else {
|
|
102
|
+
showStatus(container, 'Please drop a .zip archive', 'error');
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Register
|
|
107
|
+
registerBtn.addEventListener('click', async () => {
|
|
108
|
+
const name = nameInput.value.trim();
|
|
109
|
+
if (!name) { showStatus(container, 'Enter a shoppe name first', 'error'); return; }
|
|
110
|
+
registerBtn.disabled = true;
|
|
111
|
+
registerBtn.textContent = 'Registering...';
|
|
112
|
+
try {
|
|
113
|
+
const resp = await fetch('/plugin/shoppe/register', {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify({ name })
|
|
117
|
+
});
|
|
118
|
+
const result = await resp.json();
|
|
119
|
+
if (result.success) {
|
|
120
|
+
nameInput.value = '';
|
|
121
|
+
showStatus(container,
|
|
122
|
+
`✅ Registered! UUID: <code>${result.tenant.uuid}</code><br>Emojicode: <strong>${result.tenant.emojicode}</strong><br>Put these in your manifest.json`,
|
|
123
|
+
'success');
|
|
124
|
+
loadTenants(container);
|
|
125
|
+
} else {
|
|
126
|
+
throw new Error(result.error || 'Registration failed');
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
showStatus(container, `❌ ${err.message}`, 'error');
|
|
130
|
+
} finally {
|
|
131
|
+
registerBtn.disabled = false;
|
|
132
|
+
registerBtn.textContent = 'Register';
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function uploadArchive(file, container) {
|
|
138
|
+
showStatus(container, `⏳ Uploading and processing <strong>${file.name}</strong>...`, 'info');
|
|
139
|
+
|
|
140
|
+
const form = new FormData();
|
|
141
|
+
form.append('archive', file);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const resp = await fetch('/plugin/shoppe/upload', { method: 'POST', body: form });
|
|
145
|
+
const result = await resp.json();
|
|
146
|
+
|
|
147
|
+
if (result.success) {
|
|
148
|
+
const r = result.results;
|
|
149
|
+
const counts = [
|
|
150
|
+
r.books.length && `📚 ${r.books.length} book${r.books.length !== 1 ? 's' : ''}`,
|
|
151
|
+
r.music.length && `🎵 ${r.music.length} music item${r.music.length !== 1 ? 's' : ''}`,
|
|
152
|
+
r.posts.length && `📝 ${r.posts.length} post${r.posts.length !== 1 ? 's' : ''}`,
|
|
153
|
+
r.albums.length && `🖼️ ${r.albums.length} album${r.albums.length !== 1 ? 's' : ''}`,
|
|
154
|
+
r.products.length && `📦 ${r.products.length} product${r.products.length !== 1 ? 's' : ''}`
|
|
155
|
+
].filter(Boolean).join(', ') || 'nothing uploaded';
|
|
156
|
+
|
|
157
|
+
showStatus(container,
|
|
158
|
+
`✅ Uploaded for <strong>${result.tenant.name}</strong> ${result.tenant.emojicode}<br>
|
|
159
|
+
<div class="shoppe-result-counts">${counts}</div>
|
|
160
|
+
<a href="/plugin/shoppe/${result.tenant.uuid}" target="_blank" style="display:inline-block;margin-top:10px;color:#0066cc;">View shoppe →</a>`,
|
|
161
|
+
'success');
|
|
162
|
+
loadTenants(container);
|
|
163
|
+
} else {
|
|
164
|
+
throw new Error(result.error || 'Upload failed');
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
showStatus(container, `❌ ${err.message}`, 'error');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function loadTenants(container) {
|
|
172
|
+
const list = container.querySelector('#shoppe-tenants-list');
|
|
173
|
+
if (!list) return;
|
|
174
|
+
try {
|
|
175
|
+
const resp = await fetch('/plugin/shoppe/tenants');
|
|
176
|
+
if (!resp.ok) {
|
|
177
|
+
list.innerHTML = '<em style="font-size:13px;color:#999">Not available (owner only)</em>';
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const result = await resp.json();
|
|
181
|
+
if (!result.success || result.tenants.length === 0) {
|
|
182
|
+
list.innerHTML = '<em style="font-size:13px;color:#999">No shoppes registered yet.</em>';
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
list.innerHTML = result.tenants.map(t => `
|
|
186
|
+
<div class="shoppe-tenant">
|
|
187
|
+
<div class="shoppe-tenant-info">
|
|
188
|
+
<span class="shoppe-tenant-name">${t.name}</span>
|
|
189
|
+
<span class="shoppe-tenant-code">${t.emojicode}</span>
|
|
190
|
+
<code style="font-size:11px;color:#888">${t.uuid}</code>
|
|
191
|
+
</div>
|
|
192
|
+
<a class="shoppe-tenant-link" href="${t.url}" target="_blank">View shoppe →</a>
|
|
193
|
+
</div>
|
|
194
|
+
`).join('');
|
|
195
|
+
} catch (err) {
|
|
196
|
+
list.innerHTML = '<em style="font-size:13px;color:#999">Could not load tenants.</em>';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function showStatus(container, html, type) {
|
|
201
|
+
const el = container.querySelector('#shoppe-status');
|
|
202
|
+
el.className = `shoppe-status ${type}`;
|
|
203
|
+
el.innerHTML = html;
|
|
204
|
+
el.style.display = 'block';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
})();
|
package/factory.json
ADDED
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('./server/server.js');
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wiki-plugin-shoppe",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Multi-tenant digital goods shoppe for federated wiki, powered by Sanora",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"wiki",
|
|
7
|
+
"federated",
|
|
8
|
+
"shoppe",
|
|
9
|
+
"storefront",
|
|
10
|
+
"planet-nine",
|
|
11
|
+
"sanora"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "planetnineisaspaceship",
|
|
15
|
+
"type": "commonjs",
|
|
16
|
+
"main": "index.js",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"adm-zip": "^0.5.10",
|
|
22
|
+
"form-data": "^4.0.0",
|
|
23
|
+
"multer": "^1.4.5-lts.1",
|
|
24
|
+
"node-fetch": "^2.6.1",
|
|
25
|
+
"sessionless-node": "^0.9.12"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/server/server.js
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fetch = require('node-fetch');
|
|
5
|
+
const multer = require('multer');
|
|
6
|
+
const FormData = require('form-data');
|
|
7
|
+
const AdmZip = require('adm-zip');
|
|
8
|
+
const sessionless = require('sessionless-node');
|
|
9
|
+
|
|
10
|
+
const SANORA_PORT = process.env.SANORA_PORT || 7243;
|
|
11
|
+
const SHOPPE_BASE_EMOJI = process.env.SHOPPE_BASE_EMOJI || '🛍️🎨🎁';
|
|
12
|
+
|
|
13
|
+
const TENANTS_FILE = path.join(__dirname, '../.shoppe-tenants.json');
|
|
14
|
+
const TMP_DIR = '/tmp/shoppe-uploads';
|
|
15
|
+
|
|
16
|
+
// Same diverse palette as BDO emojicoding
|
|
17
|
+
const EMOJI_PALETTE = [
|
|
18
|
+
'🌟', '🌙', '🌍', '🌊', '🔥', '💎', '🎨', '🎭', '🎪', '🎯',
|
|
19
|
+
'🎲', '🎸', '🎹', '🎺', '🎻', '🏆', '🏹', '🏺', '🏰', '🏔',
|
|
20
|
+
'🐉', '🐙', '🐚', '🐝', '🐞', '🐢', '🐳', '🐺', '🐻', '🐼',
|
|
21
|
+
'👑', '👒', '👓', '👔', '👕', '💀', '💡', '💣', '💫', '💰',
|
|
22
|
+
'💼', '📌', '📍', '📎', '📐', '📑', '📕', '📗', '📘', '📙',
|
|
23
|
+
'📚', '📝', '📡', '📢', '📣', '📦', '📧', '📨', '📬', '📮',
|
|
24
|
+
'🔑', '🔒', '🔓', '🔔', '🔨', '🔩', '🔪', '🔫', '🔮', '🔱',
|
|
25
|
+
'🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙',
|
|
26
|
+
'🗝', '🗡', '🗿', '😀', '😁', '😂', '😃', '😄', '😅', '😆',
|
|
27
|
+
'🙂', '🙃', '🙄', '🚀', '🚁', '🚂', '🚃', '🚄', '🚅', '🚆'
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const BOOK_EXTS = new Set(['.epub', '.pdf', '.mobi', '.azw', '.azw3']);
|
|
31
|
+
const MUSIC_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav']);
|
|
32
|
+
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']);
|
|
33
|
+
|
|
34
|
+
// ============================================================
|
|
35
|
+
// TENANT MANAGEMENT
|
|
36
|
+
// ============================================================
|
|
37
|
+
|
|
38
|
+
function loadTenants() {
|
|
39
|
+
if (!fs.existsSync(TENANTS_FILE)) return {};
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(TENANTS_FILE, 'utf8'));
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.warn('[shoppe] Failed to load tenants:', err.message);
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function saveTenants(tenants) {
|
|
49
|
+
fs.writeFileSync(TENANTS_FILE, JSON.stringify(tenants, null, 2));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function generateEmojicode(tenants) {
|
|
53
|
+
const base = [...SHOPPE_BASE_EMOJI].slice(0, 3).join('');
|
|
54
|
+
const existing = new Set(Object.values(tenants).map(t => t.emojicode));
|
|
55
|
+
for (let i = 0; i < 100; i++) {
|
|
56
|
+
const shuffled = [...EMOJI_PALETTE].sort(() => Math.random() - 0.5);
|
|
57
|
+
const code = base + shuffled.slice(0, 5).join('');
|
|
58
|
+
if (!existing.has(code)) return code;
|
|
59
|
+
}
|
|
60
|
+
throw new Error('Failed to generate unique emojicode after 100 attempts');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function registerTenant(name) {
|
|
64
|
+
const tenants = loadTenants();
|
|
65
|
+
|
|
66
|
+
// Create a dedicated Sanora user for this tenant
|
|
67
|
+
const keys = await sessionless.generateKeys(() => {}, () => null);
|
|
68
|
+
const timestamp = Date.now().toString();
|
|
69
|
+
const message = timestamp + keys.pubKey;
|
|
70
|
+
const signature = await sessionless.sign(message, keys.privateKey);
|
|
71
|
+
|
|
72
|
+
const resp = await fetch(`http://localhost:${SANORA_PORT}/user/create`, {
|
|
73
|
+
method: 'PUT',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({ timestamp, pubKey: keys.pubKey, signature })
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const sanoraUser = await resp.json();
|
|
79
|
+
if (sanoraUser.error) throw new Error(`Sanora: ${sanoraUser.error}`);
|
|
80
|
+
|
|
81
|
+
const emojicode = generateEmojicode(tenants);
|
|
82
|
+
|
|
83
|
+
const tenant = {
|
|
84
|
+
uuid: sanoraUser.uuid,
|
|
85
|
+
emojicode,
|
|
86
|
+
name: name || 'Unnamed Shoppe',
|
|
87
|
+
keys,
|
|
88
|
+
sanoraUser,
|
|
89
|
+
createdAt: Date.now()
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
tenants[sanoraUser.uuid] = tenant;
|
|
93
|
+
saveTenants(tenants);
|
|
94
|
+
|
|
95
|
+
console.log(`[shoppe] Registered tenant: "${name}" ${emojicode} (${sanoraUser.uuid})`);
|
|
96
|
+
return { uuid: sanoraUser.uuid, emojicode, name: tenant.name };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getTenantByIdentifier(identifier) {
|
|
100
|
+
const tenants = loadTenants();
|
|
101
|
+
if (tenants[identifier]) return tenants[identifier];
|
|
102
|
+
return Object.values(tenants).find(t => t.emojicode === identifier) || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ============================================================
|
|
106
|
+
// SANORA API HELPERS
|
|
107
|
+
// ============================================================
|
|
108
|
+
|
|
109
|
+
function getMimeType(filename) {
|
|
110
|
+
const ext = path.extname(filename).toLowerCase();
|
|
111
|
+
return ({
|
|
112
|
+
'.epub': 'application/epub+zip',
|
|
113
|
+
'.pdf': 'application/pdf',
|
|
114
|
+
'.mobi': 'application/x-mobipocket-ebook',
|
|
115
|
+
'.mp3': 'audio/mpeg',
|
|
116
|
+
'.flac': 'audio/flac',
|
|
117
|
+
'.m4a': 'audio/mp4',
|
|
118
|
+
'.ogg': 'audio/ogg',
|
|
119
|
+
'.wav': 'audio/wav',
|
|
120
|
+
'.md': 'text/markdown',
|
|
121
|
+
'.jpg': 'image/jpeg',
|
|
122
|
+
'.jpeg': 'image/jpeg',
|
|
123
|
+
'.png': 'image/png',
|
|
124
|
+
'.gif': 'image/gif',
|
|
125
|
+
'.webp': 'image/webp',
|
|
126
|
+
'.svg': 'image/svg+xml'
|
|
127
|
+
})[ext] || 'application/octet-stream';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function sanoraCreateProduct(tenant, title, category, description, price, shipping, tags) {
|
|
131
|
+
const { uuid, keys } = tenant;
|
|
132
|
+
const timestamp = Date.now().toString();
|
|
133
|
+
const safePrice = price || 0;
|
|
134
|
+
const message = timestamp + uuid + title + (description || '') + safePrice;
|
|
135
|
+
|
|
136
|
+
sessionless.getKeys = () => keys;
|
|
137
|
+
const signature = await sessionless.sign(message);
|
|
138
|
+
|
|
139
|
+
const resp = await fetch(
|
|
140
|
+
`http://localhost:${SANORA_PORT}/user/${uuid}/product/${encodeURIComponent(title)}`,
|
|
141
|
+
{
|
|
142
|
+
method: 'PUT',
|
|
143
|
+
headers: { 'Content-Type': 'application/json' },
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
timestamp,
|
|
146
|
+
pubKey: keys.pubKey,
|
|
147
|
+
signature,
|
|
148
|
+
description: description || '',
|
|
149
|
+
price: safePrice,
|
|
150
|
+
shipping: shipping || 0,
|
|
151
|
+
category,
|
|
152
|
+
tags: tags || category
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const product = await resp.json();
|
|
158
|
+
if (product.error) throw new Error(`Create product failed: ${product.error}`);
|
|
159
|
+
return product;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function sanoraUploadArtifact(tenant, title, fileBuffer, filename, artifactType) {
|
|
163
|
+
const { uuid, keys } = tenant;
|
|
164
|
+
const timestamp = Date.now().toString();
|
|
165
|
+
sessionless.getKeys = () => keys;
|
|
166
|
+
const message = timestamp + uuid + title;
|
|
167
|
+
const signature = await sessionless.sign(message);
|
|
168
|
+
|
|
169
|
+
const form = new FormData();
|
|
170
|
+
form.append('artifact', fileBuffer, { filename, contentType: getMimeType(filename) });
|
|
171
|
+
|
|
172
|
+
const resp = await fetch(
|
|
173
|
+
`http://localhost:${SANORA_PORT}/user/${uuid}/product/${encodeURIComponent(title)}/artifact`,
|
|
174
|
+
{
|
|
175
|
+
method: 'PUT',
|
|
176
|
+
headers: {
|
|
177
|
+
'x-pn-artifact-type': artifactType,
|
|
178
|
+
'x-pn-timestamp': timestamp,
|
|
179
|
+
'x-pn-signature': signature,
|
|
180
|
+
...form.getHeaders()
|
|
181
|
+
},
|
|
182
|
+
body: form
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const result = await resp.json();
|
|
187
|
+
if (result.error) throw new Error(`Artifact upload failed: ${result.error}`);
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function sanoraUploadImage(tenant, title, imageBuffer, filename) {
|
|
192
|
+
const { uuid, keys } = tenant;
|
|
193
|
+
const timestamp = Date.now().toString();
|
|
194
|
+
sessionless.getKeys = () => keys;
|
|
195
|
+
const message = timestamp + uuid + title;
|
|
196
|
+
const signature = await sessionless.sign(message);
|
|
197
|
+
|
|
198
|
+
const form = new FormData();
|
|
199
|
+
form.append('image', imageBuffer, { filename, contentType: getMimeType(filename) });
|
|
200
|
+
|
|
201
|
+
const resp = await fetch(
|
|
202
|
+
`http://localhost:${SANORA_PORT}/user/${uuid}/product/${encodeURIComponent(title)}/image`,
|
|
203
|
+
{
|
|
204
|
+
method: 'PUT',
|
|
205
|
+
headers: {
|
|
206
|
+
'x-pn-timestamp': timestamp,
|
|
207
|
+
'x-pn-signature': signature,
|
|
208
|
+
...form.getHeaders()
|
|
209
|
+
},
|
|
210
|
+
body: form
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const result = await resp.json();
|
|
215
|
+
if (result.error) throw new Error(`Image upload failed: ${result.error}`);
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============================================================
|
|
220
|
+
// ARCHIVE PROCESSING
|
|
221
|
+
// ============================================================
|
|
222
|
+
|
|
223
|
+
async function processArchive(zipPath) {
|
|
224
|
+
const zip = new AdmZip(zipPath);
|
|
225
|
+
const tmpDir = path.join(TMP_DIR, `extract-${Date.now()}`);
|
|
226
|
+
zip.extractAllTo(tmpDir, true);
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
// Read and validate manifest
|
|
230
|
+
const manifestPath = path.join(tmpDir, 'manifest.json');
|
|
231
|
+
if (!fs.existsSync(manifestPath)) {
|
|
232
|
+
throw new Error('Archive is missing manifest.json');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
236
|
+
if (!manifest.uuid || !manifest.emojicode) {
|
|
237
|
+
throw new Error('manifest.json must contain uuid and emojicode');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const tenant = getTenantByIdentifier(manifest.uuid);
|
|
241
|
+
if (!tenant) throw new Error(`Unknown UUID: ${manifest.uuid}`);
|
|
242
|
+
if (tenant.emojicode !== manifest.emojicode) {
|
|
243
|
+
throw new Error('emojicode does not match registered tenant');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const results = { books: [], music: [], posts: [], albums: [], products: [] };
|
|
247
|
+
|
|
248
|
+
// ---- books/ ----
|
|
249
|
+
const booksDir = path.join(tmpDir, 'books');
|
|
250
|
+
if (fs.existsSync(booksDir)) {
|
|
251
|
+
for (const file of fs.readdirSync(booksDir)) {
|
|
252
|
+
if (!BOOK_EXTS.has(path.extname(file).toLowerCase())) continue;
|
|
253
|
+
const title = path.basename(file, path.extname(file));
|
|
254
|
+
try {
|
|
255
|
+
const buf = fs.readFileSync(path.join(booksDir, file));
|
|
256
|
+
await sanoraCreateProduct(tenant, title, 'book', `Book: ${title}`, 0, 0, 'book');
|
|
257
|
+
await sanoraUploadArtifact(tenant, title, buf, file, 'ebook');
|
|
258
|
+
results.books.push({ title });
|
|
259
|
+
console.log(`[shoppe] 📚 book: ${title}`);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
console.warn(`[shoppe] ⚠️ book ${file}: ${err.message}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- music/ ----
|
|
267
|
+
// Albums are subfolders; standalone files are individual tracks
|
|
268
|
+
const musicDir = path.join(tmpDir, 'music');
|
|
269
|
+
if (fs.existsSync(musicDir)) {
|
|
270
|
+
for (const entry of fs.readdirSync(musicDir)) {
|
|
271
|
+
const entryPath = path.join(musicDir, entry);
|
|
272
|
+
const stat = fs.statSync(entryPath);
|
|
273
|
+
|
|
274
|
+
if (stat.isDirectory()) {
|
|
275
|
+
// Album
|
|
276
|
+
const albumName = entry;
|
|
277
|
+
const tracks = fs.readdirSync(entryPath).filter(f => MUSIC_EXTS.has(path.extname(f).toLowerCase()));
|
|
278
|
+
const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
279
|
+
try {
|
|
280
|
+
await sanoraCreateProduct(tenant, albumName, 'music', `Album: ${albumName}`, 0, 0, 'music,album');
|
|
281
|
+
if (covers.length > 0) {
|
|
282
|
+
const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
|
|
283
|
+
await sanoraUploadImage(tenant, albumName, coverBuf, covers[0]);
|
|
284
|
+
}
|
|
285
|
+
for (const track of tracks) {
|
|
286
|
+
const buf = fs.readFileSync(path.join(entryPath, track));
|
|
287
|
+
await sanoraUploadArtifact(tenant, albumName, buf, track, 'audio');
|
|
288
|
+
}
|
|
289
|
+
results.music.push({ title: albumName, type: 'album', tracks: tracks.length });
|
|
290
|
+
console.log(`[shoppe] 🎵 album: ${albumName} (${tracks.length} tracks)`);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.warn(`[shoppe] ⚠️ album ${albumName}: ${err.message}`);
|
|
293
|
+
}
|
|
294
|
+
} else if (MUSIC_EXTS.has(path.extname(entry).toLowerCase())) {
|
|
295
|
+
// Standalone track
|
|
296
|
+
const title = path.basename(entry, path.extname(entry));
|
|
297
|
+
try {
|
|
298
|
+
const buf = fs.readFileSync(entryPath);
|
|
299
|
+
await sanoraCreateProduct(tenant, title, 'music', `Track: ${title}`, 0, 0, 'music,track');
|
|
300
|
+
await sanoraUploadArtifact(tenant, title, buf, entry, 'audio');
|
|
301
|
+
results.music.push({ title, type: 'track' });
|
|
302
|
+
console.log(`[shoppe] 🎵 track: ${title}`);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
console.warn(`[shoppe] ⚠️ track ${entry}: ${err.message}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---- posts/ ----
|
|
311
|
+
const postsDir = path.join(tmpDir, 'posts');
|
|
312
|
+
if (fs.existsSync(postsDir)) {
|
|
313
|
+
for (const file of fs.readdirSync(postsDir)) {
|
|
314
|
+
if (!file.endsWith('.md')) continue;
|
|
315
|
+
const title = path.basename(file, '.md');
|
|
316
|
+
try {
|
|
317
|
+
const buf = fs.readFileSync(path.join(postsDir, file));
|
|
318
|
+
const firstLine = buf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
|
|
319
|
+
await sanoraCreateProduct(tenant, title, 'post', firstLine || title, 0, 0, 'post,blog');
|
|
320
|
+
await sanoraUploadArtifact(tenant, title, buf, file, 'text');
|
|
321
|
+
results.posts.push({ title });
|
|
322
|
+
console.log(`[shoppe] 📝 post: ${title}`);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
console.warn(`[shoppe] ⚠️ post ${file}: ${err.message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---- albums/ ----
|
|
330
|
+
// Each subfolder is a photo album
|
|
331
|
+
const albumsDir = path.join(tmpDir, 'albums');
|
|
332
|
+
if (fs.existsSync(albumsDir)) {
|
|
333
|
+
for (const entry of fs.readdirSync(albumsDir)) {
|
|
334
|
+
const entryPath = path.join(albumsDir, entry);
|
|
335
|
+
if (!fs.statSync(entryPath).isDirectory()) continue;
|
|
336
|
+
const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
337
|
+
try {
|
|
338
|
+
await sanoraCreateProduct(tenant, entry, 'album', `Photo album: ${entry}`, 0, 0, 'album,photos');
|
|
339
|
+
if (images.length > 0) {
|
|
340
|
+
const coverBuf = fs.readFileSync(path.join(entryPath, images[0]));
|
|
341
|
+
await sanoraUploadImage(tenant, entry, coverBuf, images[0]);
|
|
342
|
+
}
|
|
343
|
+
for (const img of images) {
|
|
344
|
+
const buf = fs.readFileSync(path.join(entryPath, img));
|
|
345
|
+
await sanoraUploadArtifact(tenant, entry, buf, img, 'image');
|
|
346
|
+
}
|
|
347
|
+
results.albums.push({ title: entry, images: images.length });
|
|
348
|
+
console.log(`[shoppe] 🖼️ album: ${entry} (${images.length} images)`);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
console.warn(`[shoppe] ⚠️ album ${entry}: ${err.message}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---- products/ ----
|
|
356
|
+
// Each subfolder is a physical product with cover.jpg + info.json
|
|
357
|
+
const productsDir = path.join(tmpDir, 'products');
|
|
358
|
+
if (fs.existsSync(productsDir)) {
|
|
359
|
+
for (const entry of fs.readdirSync(productsDir)) {
|
|
360
|
+
const entryPath = path.join(productsDir, entry);
|
|
361
|
+
if (!fs.statSync(entryPath).isDirectory()) continue;
|
|
362
|
+
try {
|
|
363
|
+
const infoPath = path.join(entryPath, 'info.json');
|
|
364
|
+
const info = fs.existsSync(infoPath)
|
|
365
|
+
? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
|
|
366
|
+
: {};
|
|
367
|
+
const title = info.title || entry;
|
|
368
|
+
const description = info.description || '';
|
|
369
|
+
const price = info.price || 0;
|
|
370
|
+
const shipping = info.shipping || 0;
|
|
371
|
+
|
|
372
|
+
await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, 'product,physical');
|
|
373
|
+
|
|
374
|
+
const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
|
|
375
|
+
if (images.length > 0) {
|
|
376
|
+
const coverBuf = fs.readFileSync(path.join(entryPath, images[0]));
|
|
377
|
+
await sanoraUploadImage(tenant, title, coverBuf, images[0]);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
results.products.push({ title, price, shipping });
|
|
381
|
+
console.log(`[shoppe] 📦 product: ${title} ($${price} + $${shipping} shipping)`);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
console.warn(`[shoppe] ⚠️ product ${entry}: ${err.message}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
tenant: { uuid: tenant.uuid, emojicode: tenant.emojicode, name: tenant.name },
|
|
390
|
+
results
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
} finally {
|
|
394
|
+
try { fs.rmSync(tmpDir, { recursive: true }); } catch (e) {}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ============================================================
|
|
399
|
+
// PORTFOLIO PAGE GENERATION
|
|
400
|
+
// ============================================================
|
|
401
|
+
|
|
402
|
+
async function getShoppeGoods(tenant) {
|
|
403
|
+
const resp = await fetch(`http://localhost:${SANORA_PORT}/products/${tenant.uuid}`);
|
|
404
|
+
const products = await resp.json();
|
|
405
|
+
|
|
406
|
+
const goods = { books: [], music: [], posts: [], albums: [], products: [] };
|
|
407
|
+
|
|
408
|
+
for (const [title, product] of Object.entries(products)) {
|
|
409
|
+
const item = {
|
|
410
|
+
title: product.title || title,
|
|
411
|
+
description: product.description || '',
|
|
412
|
+
price: product.price || 0,
|
|
413
|
+
shipping: product.shipping || 0,
|
|
414
|
+
image: product.image ? `http://localhost:${SANORA_PORT}/images/${product.image}` : null,
|
|
415
|
+
url: `http://localhost:${SANORA_PORT}/products/${tenant.uuid}/${encodeURIComponent(title)}`
|
|
416
|
+
};
|
|
417
|
+
const bucket = goods[product.category];
|
|
418
|
+
if (bucket) bucket.push(item);
|
|
419
|
+
else goods.products.push(item);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return goods;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const CATEGORY_EMOJI = { book: '📚', music: '🎵', post: '📝', album: '🖼️', product: '📦' };
|
|
426
|
+
|
|
427
|
+
function renderCards(items, category) {
|
|
428
|
+
if (items.length === 0) {
|
|
429
|
+
return '<p class="empty">Nothing here yet.</p>';
|
|
430
|
+
}
|
|
431
|
+
return items.map(item => {
|
|
432
|
+
const imgHtml = item.image
|
|
433
|
+
? `<div class="card-img"><img src="${item.image}" alt="" loading="lazy"></div>`
|
|
434
|
+
: `<div class="card-img-placeholder">${CATEGORY_EMOJI[category] || '🎁'}</div>`;
|
|
435
|
+
const priceHtml = (item.price > 0 || category === 'product')
|
|
436
|
+
? `<div class="price">$${item.price}${item.shipping ? ` <span class="shipping">+ $${item.shipping} shipping</span>` : ''}</div>`
|
|
437
|
+
: '';
|
|
438
|
+
return `
|
|
439
|
+
<div class="card" onclick="window.open('${item.url}','_blank')">
|
|
440
|
+
${imgHtml}
|
|
441
|
+
<div class="card-body">
|
|
442
|
+
<div class="card-title">${item.title}</div>
|
|
443
|
+
${item.description ? `<div class="card-desc">${item.description}</div>` : ''}
|
|
444
|
+
${priceHtml}
|
|
445
|
+
</div>
|
|
446
|
+
</div>`;
|
|
447
|
+
}).join('');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function generateShoppeHTML(tenant, goods) {
|
|
451
|
+
const total = Object.values(goods).flat().length;
|
|
452
|
+
const tabs = [
|
|
453
|
+
{ id: 'all', label: 'All', count: total, always: true },
|
|
454
|
+
{ id: 'books', label: '📚 Books', count: goods.books.length },
|
|
455
|
+
{ id: 'music', label: '🎵 Music', count: goods.music.length },
|
|
456
|
+
{ id: 'posts', label: '📝 Posts', count: goods.posts.length },
|
|
457
|
+
{ id: 'albums', label: '🖼️ Albums', count: goods.albums.length },
|
|
458
|
+
{ id: 'products', label: '📦 Products', count: goods.products.length }
|
|
459
|
+
]
|
|
460
|
+
.filter(t => t.always || t.count > 0)
|
|
461
|
+
.map((t, i) => `<div class="tab${i === 0 ? ' active' : ''}" onclick="show('${t.id}',this)">${t.label} <span class="badge">${t.count}</span></div>`)
|
|
462
|
+
.join('');
|
|
463
|
+
|
|
464
|
+
const allItems = [...goods.books, ...goods.music, ...goods.posts, ...goods.albums, ...goods.products];
|
|
465
|
+
|
|
466
|
+
return `<!DOCTYPE html>
|
|
467
|
+
<html lang="en">
|
|
468
|
+
<head>
|
|
469
|
+
<meta charset="UTF-8">
|
|
470
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
471
|
+
<title>${tenant.name}</title>
|
|
472
|
+
<style>
|
|
473
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
474
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f7; color: #1d1d1f; }
|
|
475
|
+
header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: white; padding: 48px 24px 40px; text-align: center; }
|
|
476
|
+
.emojicode { font-size: 30px; letter-spacing: 6px; margin-bottom: 14px; }
|
|
477
|
+
header h1 { font-size: 38px; font-weight: 700; margin-bottom: 6px; }
|
|
478
|
+
.count { opacity: 0.65; font-size: 15px; }
|
|
479
|
+
nav { display: flex; overflow-x: auto; background: white; border-bottom: 1px solid #ddd; padding: 0 20px; gap: 0; }
|
|
480
|
+
.tab { padding: 14px 18px; cursor: pointer; font-size: 14px; font-weight: 500; white-space: nowrap; border-bottom: 2px solid transparent; color: #555; transition: color 0.15s, border-color 0.15s; }
|
|
481
|
+
.tab:hover { color: #0066cc; }
|
|
482
|
+
.tab.active { color: #0066cc; border-bottom-color: #0066cc; }
|
|
483
|
+
.badge { background: #e8f0fe; color: #0066cc; border-radius: 10px; padding: 1px 7px; font-size: 11px; margin-left: 5px; }
|
|
484
|
+
main { max-width: 1200px; margin: 0 auto; padding: 36px 24px; }
|
|
485
|
+
.section { display: none; }
|
|
486
|
+
.section.active { display: block; }
|
|
487
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 20px; }
|
|
488
|
+
.card { background: white; border-radius: 14px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.07); cursor: pointer; transition: transform 0.18s, box-shadow 0.18s; }
|
|
489
|
+
.card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px rgba(0,0,0,0.12); }
|
|
490
|
+
.card-img img { width: 100%; height: 190px; object-fit: cover; display: block; }
|
|
491
|
+
.card-img-placeholder { height: 110px; display: flex; align-items: center; justify-content: center; font-size: 44px; background: #f0f0f7; }
|
|
492
|
+
.card-body { padding: 16px; }
|
|
493
|
+
.card-title { font-size: 15px; font-weight: 600; margin-bottom: 5px; line-height: 1.3; }
|
|
494
|
+
.card-desc { font-size: 13px; color: #666; margin-bottom: 8px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
495
|
+
.price { font-size: 15px; font-weight: 700; color: #0066cc; }
|
|
496
|
+
.shipping { font-size: 12px; font-weight: 400; color: #888; }
|
|
497
|
+
.empty { color: #999; text-align: center; padding: 60px 0; font-size: 15px; }
|
|
498
|
+
</style>
|
|
499
|
+
</head>
|
|
500
|
+
<body>
|
|
501
|
+
<header>
|
|
502
|
+
<div class="emojicode">${tenant.emojicode}</div>
|
|
503
|
+
<h1>${tenant.name}</h1>
|
|
504
|
+
<div class="count">${total} item${total !== 1 ? 's' : ''}</div>
|
|
505
|
+
</header>
|
|
506
|
+
<nav>${tabs}</nav>
|
|
507
|
+
<main>
|
|
508
|
+
<div id="all" class="section active"><div class="grid">${renderCards(allItems, 'all')}</div></div>
|
|
509
|
+
<div id="books" class="section"><div class="grid">${renderCards(goods.books, 'book')}</div></div>
|
|
510
|
+
<div id="music" class="section"><div class="grid">${renderCards(goods.music, 'music')}</div></div>
|
|
511
|
+
<div id="posts" class="section"><div class="grid">${renderCards(goods.posts, 'post')}</div></div>
|
|
512
|
+
<div id="albums" class="section"><div class="grid">${renderCards(goods.albums, 'album')}</div></div>
|
|
513
|
+
<div id="products" class="section"><div class="grid">${renderCards(goods.products, 'product')}</div></div>
|
|
514
|
+
</main>
|
|
515
|
+
<script>
|
|
516
|
+
function show(id, tab) {
|
|
517
|
+
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
|
518
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
519
|
+
document.getElementById(id).classList.add('active');
|
|
520
|
+
tab.classList.add('active');
|
|
521
|
+
}
|
|
522
|
+
</script>
|
|
523
|
+
</body>
|
|
524
|
+
</html>`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ============================================================
|
|
528
|
+
// EXPRESS ROUTES
|
|
529
|
+
// ============================================================
|
|
530
|
+
|
|
531
|
+
async function startServer(params) {
|
|
532
|
+
const app = params.app;
|
|
533
|
+
|
|
534
|
+
if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
|
|
535
|
+
console.log('🛍️ wiki-plugin-shoppe starting...');
|
|
536
|
+
|
|
537
|
+
const owner = (req, res, next) => {
|
|
538
|
+
if (!app.securityhandler.isAuthorized(req)) {
|
|
539
|
+
return res.status(401).json({ error: 'must be owner' });
|
|
540
|
+
}
|
|
541
|
+
return next();
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const upload = multer({
|
|
545
|
+
dest: TMP_DIR,
|
|
546
|
+
limits: { fileSize: 500 * 1024 * 1024 } // 500 MB
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// Register a new tenant (owner only)
|
|
550
|
+
app.post('/plugin/shoppe/register', owner, async (req, res) => {
|
|
551
|
+
try {
|
|
552
|
+
const tenant = await registerTenant(req.body.name);
|
|
553
|
+
res.json({ success: true, tenant });
|
|
554
|
+
} catch (err) {
|
|
555
|
+
console.error('[shoppe] register error:', err);
|
|
556
|
+
res.status(500).json({ success: false, error: err.message });
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// List all tenants (owner only)
|
|
561
|
+
app.get('/plugin/shoppe/tenants', owner, (req, res) => {
|
|
562
|
+
const tenants = loadTenants();
|
|
563
|
+
const safe = Object.values(tenants).map(({ uuid, emojicode, name, createdAt }) => ({
|
|
564
|
+
uuid, emojicode, name, createdAt,
|
|
565
|
+
url: `/plugin/shoppe/${uuid}`
|
|
566
|
+
}));
|
|
567
|
+
res.json({ success: true, tenants: safe });
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// Upload goods archive (auth via manifest uuid+emojicode)
|
|
571
|
+
app.post('/plugin/shoppe/upload', upload.single('archive'), async (req, res) => {
|
|
572
|
+
try {
|
|
573
|
+
if (!req.file) {
|
|
574
|
+
return res.status(400).json({ success: false, error: 'No archive uploaded' });
|
|
575
|
+
}
|
|
576
|
+
console.log('[shoppe] Processing archive:', req.file.originalname);
|
|
577
|
+
const result = await processArchive(req.file.path);
|
|
578
|
+
res.json({ success: true, ...result });
|
|
579
|
+
} catch (err) {
|
|
580
|
+
console.error('[shoppe] upload error:', err);
|
|
581
|
+
res.status(500).json({ success: false, error: err.message });
|
|
582
|
+
} finally {
|
|
583
|
+
if (req.file && fs.existsSync(req.file.path)) {
|
|
584
|
+
try { fs.unlinkSync(req.file.path); } catch (e) {}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// Goods JSON (public)
|
|
590
|
+
app.get('/plugin/shoppe/:identifier/goods', async (req, res) => {
|
|
591
|
+
try {
|
|
592
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
593
|
+
if (!tenant) return res.status(404).json({ error: 'Shoppe not found' });
|
|
594
|
+
const goods = await getShoppeGoods(tenant);
|
|
595
|
+
const cat = req.query.category;
|
|
596
|
+
res.json({ success: true, goods: (cat && goods[cat]) ? goods[cat] : goods });
|
|
597
|
+
} catch (err) {
|
|
598
|
+
res.status(500).json({ error: err.message });
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// Shoppe HTML page (public)
|
|
603
|
+
app.get('/plugin/shoppe/:identifier', async (req, res) => {
|
|
604
|
+
try {
|
|
605
|
+
const tenant = getTenantByIdentifier(req.params.identifier);
|
|
606
|
+
if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
|
|
607
|
+
const goods = await getShoppeGoods(tenant);
|
|
608
|
+
res.set('Content-Type', 'text/html');
|
|
609
|
+
res.send(generateShoppeHTML(tenant, goods));
|
|
610
|
+
} catch (err) {
|
|
611
|
+
console.error('[shoppe] page error:', err);
|
|
612
|
+
res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
console.log('✅ wiki-plugin-shoppe ready!');
|
|
617
|
+
console.log(' POST /plugin/shoppe/register — register tenant (owner)');
|
|
618
|
+
console.log(' GET /plugin/shoppe/tenants — list tenants (owner)');
|
|
619
|
+
console.log(' POST /plugin/shoppe/upload — upload goods archive');
|
|
620
|
+
console.log(' GET /plugin/shoppe/:id — shoppe page');
|
|
621
|
+
console.log(' GET /plugin/shoppe/:id/goods — goods JSON');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
module.exports = { startServer };
|
|
625
|
+
}).call(this);
|