Compare commits
5
Commits
29d2ace1b2
...
684181c34c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
684181c34c | ||
|
|
a12a471c41 | ||
|
|
1e396644a9 | ||
|
|
6a03813914 | ||
|
|
afdc00114a |
No files matched your search
@@ -10,6 +10,13 @@ services:
|
||||
- SITEFORGE_DATA_DIR=/app/data
|
||||
- ADMIN_PASSWORD=`generate a random password and set it here`
|
||||
- RESEND_API_KEY=`set your Resend API key here`
|
||||
# Seafile integration for Nuggets host upload portal
|
||||
# Set SEAFILE_TOKEN and SEAFILE_NUGGETS_REPO_ID in a .env file alongside this compose file
|
||||
- SEAFILE_URL=https://seafile.necloud.us
|
||||
- SEAFILE_TOKEN=${SEAFILE_TOKEN}
|
||||
- SEAFILE_NUGGETS_REPO_ID=${SEAFILE_NUGGETS_REPO_ID}
|
||||
# Upload notification email (defaults to RESEND_TO if not set)
|
||||
- NUGGETS_NOTIFY_EMAIL=${NUGGETS_NOTIFY_EMAIL}
|
||||
volumes:
|
||||
- /media/ZimaOS-HD/AppData/siteforge/data:/app/data
|
||||
restart: unless-stopped
|
||||
Generated
+123
-1
@@ -19,6 +19,7 @@
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"fuse.js": "^7.4.2",
|
||||
"mammoth": "^1.12.0",
|
||||
"multer": "^2.3.0",
|
||||
"otplib": "^13.4.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -3903,6 +3904,12 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@@ -4163,9 +4170,19 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -4417,6 +4434,35 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
@@ -8332,6 +8378,68 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
|
||||
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -10096,6 +10204,14 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
@@ -10605,6 +10721,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -24,6 +24,7 @@
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"fuse.js": "^7.4.2",
|
||||
"mammoth": "^1.12.0",
|
||||
"multer": "^2.3.0",
|
||||
"otplib": "^13.4.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
@@ -63,6 +63,8 @@ import { register as registerAnalytics } from './server/routes/analytics.js'
|
||||
import { register as registerDownloads } from './server/routes/downloads.js'
|
||||
import { register as registerEpisodes } from './server/routes/episodes.js'
|
||||
import { register as registerNuggets } from './server/routes/nuggets.js'
|
||||
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
|
||||
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
|
||||
import { register as registerFeed } from './server/routes/feed.js'
|
||||
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
||||
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
|
||||
@@ -112,6 +114,7 @@ registerAnalytics(app)
|
||||
registerDownloads(app)
|
||||
registerEpisodes(app)
|
||||
registerNuggets(app)
|
||||
registerNuggetsHostUpload(app)
|
||||
registerFeed(app)
|
||||
registerQrCodes(app)
|
||||
registerBiblePassage(app)
|
||||
@@ -172,6 +175,7 @@ Promise.all([
|
||||
}, BACKUP_INTERVAL_MS)
|
||||
|
||||
startReminderScheduler()
|
||||
startNuggetsSeafileWatcher()
|
||||
|
||||
// Purge expired study sessions every hour
|
||||
setInterval(() => {
|
||||
|
||||
@@ -58,12 +58,28 @@ export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
|
||||
export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
|
||||
export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
|
||||
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
|
||||
export const NUGGETS_UPLOAD_HOSTS_FILE = path.join(DATA_DIR, 'nuggets-upload-hosts.json')
|
||||
export const NUGGETS_SEAFILE_SEEN_FILE = path.join(DATA_DIR, 'nuggets-seafile-seen.json')
|
||||
|
||||
// Email address for Nuggets upload notifications (defaults to the admin inbox)
|
||||
export const NUGGETS_NOTIFY_EMAIL = process.env.NUGGETS_NOTIFY_EMAIL ?? process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'
|
||||
export const SEAFILE_POLL_INTERVAL_MS = 15 * 60 * 1000 // 15 minutes
|
||||
export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
|
||||
export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json')
|
||||
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
|
||||
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
|
||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||
|
||||
// Seafile integration for Nuggets episode uploads
|
||||
export const SEAFILE_URL = (process.env.SEAFILE_URL ?? '').replace(/\/$/, '')
|
||||
export const SEAFILE_TOKEN = process.env.SEAFILE_TOKEN ?? ''
|
||||
export const SEAFILE_NUGGETS_REPO_ID = process.env.SEAFILE_NUGGETS_REPO_ID ?? ''
|
||||
export const SEAFILE_EPISODE_INFO_DIR = process.env.SEAFILE_EPISODE_INFO_DIR ?? '/Episode Info'
|
||||
export const SEAFILE_EPISODE_UPLOAD_DIR = process.env.SEAFILE_EPISODE_UPLOAD_DIR ?? '/Episode Upload'
|
||||
|
||||
export const HOST_SESSION_COOKIE = 'vbn_host_session'
|
||||
export const HOST_SESSION_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
||||
export const INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
||||
export const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
|
||||
|
||||
@@ -712,3 +712,96 @@ export async function sendStudyWeeklyDigestEmail(email, displayName, lessons, un
|
||||
console.error('[study-digest] send exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nuggets upload notifications ───────────────────────────────────────────
|
||||
|
||||
export async function sendNuggetsUploadNotification({ to, source, hostName, episodeName, passage, description, filename, infoFile }) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
|
||||
const sourceLabel = source === 'portal' ? 'Upload Portal' : 'Direct to Seafile'
|
||||
const subject = `New Nuggets Episode Upload: ${episodeName || filename}`
|
||||
|
||||
const bodyHtml = `
|
||||
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:15px;color:#2c2417;">A new episode file has arrived in Seafile.</p>
|
||||
<table style="width:100%;border-collapse:collapse;font-family:Arial,sans-serif;font-size:14px;color:#2c2417;">
|
||||
<tr><td style="padding:6px 0;color:#7a7060;width:120px;">Source</td><td style="padding:6px 0;font-weight:600;">${escapeHtml(sourceLabel)}</td></tr>
|
||||
${hostName ? `<tr><td style="padding:6px 0;color:#7a7060;">Host</td><td style="padding:6px 0;">${escapeHtml(hostName)}</td></tr>` : ''}
|
||||
${episodeName ? `<tr><td style="padding:6px 0;color:#7a7060;">Episode</td><td style="padding:6px 0;font-weight:600;">${escapeHtml(episodeName)}</td></tr>` : ''}
|
||||
${passage ? `<tr><td style="padding:6px 0;color:#7a7060;">Passage</td><td style="padding:6px 0;">${escapeHtml(passage)}</td></tr>` : ''}
|
||||
<tr><td style="padding:6px 0;color:#7a7060;">Audio File</td><td style="padding:6px 0;font-family:monospace;font-size:13px;">${escapeHtml(filename)}</td></tr>
|
||||
${infoFile ? `<tr><td style="padding:6px 0;color:#7a7060;">Info File</td><td style="padding:6px 0;font-family:monospace;font-size:13px;">${escapeHtml(infoFile)}</td></tr>` : ''}
|
||||
</table>
|
||||
${description ? `<p style="margin:16px 0 0;font-family:Arial,sans-serif;font-size:14px;color:#2c2417;"><strong>Description:</strong><br/>${escapeHtml(description).replace(/\n/g, '<br/>')}</p>` : ''}
|
||||
`
|
||||
|
||||
const textBody = [
|
||||
`New Nuggets Episode Upload (${sourceLabel})`,
|
||||
'',
|
||||
hostName ? `Host: ${hostName}` : '',
|
||||
episodeName ? `Episode: ${episodeName}` : '',
|
||||
passage ? `Passage: ${passage}` : '',
|
||||
`Audio File: ${filename}`,
|
||||
infoFile ? `Info File: ${infoFile}` : '',
|
||||
description ? `\nDescription:\n${description}` : '',
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
try {
|
||||
const { error } = await resend.emails.send({
|
||||
from,
|
||||
to: [to],
|
||||
subject,
|
||||
text: textBody,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'New Episode Upload',
|
||||
eyebrow: 'Nuggets',
|
||||
bodyHtml,
|
||||
footerHtml: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">This notification was sent because a file was uploaded to the Nuggets Seafile library.</p>',
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[nuggets-notify] send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[nuggets-notify] send exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNuggetsHostWelcomeEmail({ to, name, username, password, portalUrl }) {
|
||||
if (!process.env.RESEND_API_KEY || !to) return
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
|
||||
|
||||
const bodyHtml = `
|
||||
<p style="margin:0 0 14px;font-family:Arial,sans-serif;font-size:15px;color:#2c2417;">Hi ${escapeHtml(name)},</p>
|
||||
<p style="margin:0 0 20px;font-family:Arial,sans-serif;font-size:15px;color:#2c2417;">
|
||||
You've been set up as a host on the <strong>Nuggets</strong> episode upload portal.
|
||||
Use the details below to sign in and submit your episodes.
|
||||
</p>
|
||||
<table style="width:100%;border-collapse:collapse;font-family:Arial,sans-serif;font-size:14px;color:#2c2417;margin-bottom:20px;">
|
||||
<tr><td style="padding:8px 12px;background:#faf9f7;border:1px solid #e5e0d5;border-radius:4px;color:#7a7060;width:110px;">Portal URL</td><td style="padding:8px 12px;background:#faf9f7;border:1px solid #e5e0d5;"><a href="${escapeHtml(portalUrl)}" style="color:#c8a96e;">${escapeHtml(portalUrl)}</a></td></tr>
|
||||
<tr><td style="padding:8px 12px;background:#faf9f7;border:1px solid #e5e0d5;color:#7a7060;">Email</td><td style="padding:8px 12px;background:#faf9f7;border:1px solid #e5e0d5;font-family:monospace;font-weight:700;">${escapeHtml(username)}</td></tr>
|
||||
<tr><td style="padding:8px 12px;background:#faf9f7;border:1px solid #e5e0d5;color:#7a7060;">Password</td><td style="padding:8px 12px;background:#faf9f7;border:1px solid #e5e0d5;font-family:monospace;font-weight:700;">${escapeHtml(password)}</td></tr>
|
||||
</table>
|
||||
<p style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:14px;color:#2c2417;">When you're ready to submit an episode, fill in the episode name, the Bible passage, a short description, and attach your audio file. Everything uploads in one step.</p>
|
||||
<p style="margin:0;font-family:Arial,sans-serif;font-size:14px;color:#7a7060;">If you have any trouble signing in, reply to this email and we'll get you sorted.</p>
|
||||
`
|
||||
|
||||
try {
|
||||
const { error } = await resend.emails.send({
|
||||
from,
|
||||
to: [to],
|
||||
subject: 'Your Nuggets Host Upload Portal Access',
|
||||
text: `Hi ${name},\n\nYou've been set up as a host on the Nuggets episode upload portal.\n\nPortal URL: ${portalUrl}\nUsername: ${username}\nPassword: ${password}\n\nWhen submitting an episode, fill in the episode name, Bible passage, a short description, and attach your audio file.\n\nIf you have any trouble, reply to this email.`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'Upload Portal Access',
|
||||
eyebrow: 'Nuggets',
|
||||
bodyHtml,
|
||||
ctaLabel: 'Open Upload Portal',
|
||||
ctaUrl: portalUrl,
|
||||
footerHtml: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">You received this because an admin created an upload account for you on the Nuggets podcast portal.</p>',
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[nuggets-welcome] send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[nuggets-welcome] send exception:', err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { sendNuggetsUploadNotification } from './email.js'
|
||||
import {
|
||||
SEAFILE_URL,
|
||||
SEAFILE_TOKEN,
|
||||
SEAFILE_NUGGETS_REPO_ID,
|
||||
SEAFILE_EPISODE_UPLOAD_DIR,
|
||||
SEAFILE_EPISODE_INFO_DIR,
|
||||
SEAFILE_POLL_INTERVAL_MS,
|
||||
NUGGETS_SEAFILE_SEEN_FILE,
|
||||
NUGGETS_NOTIFY_EMAIL,
|
||||
} from './config.js'
|
||||
|
||||
async function loadSeen() {
|
||||
try {
|
||||
return JSON.parse(await readFile(NUGGETS_SEAFILE_SEEN_FILE, 'utf8'))
|
||||
} catch {
|
||||
return { files: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSeen(data) {
|
||||
await writeFile(NUGGETS_SEAFILE_SEEN_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
// Called by the upload route after a successful portal upload so the poller
|
||||
// doesn't also fire a notification for the same file.
|
||||
export async function markSeafileFileSeen(filename) {
|
||||
const data = await loadSeen()
|
||||
if (!data.files.includes(filename)) {
|
||||
data.files.push(filename)
|
||||
await saveSeen(data)
|
||||
}
|
||||
}
|
||||
|
||||
async function listSeafileDir(dir) {
|
||||
if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) return []
|
||||
try {
|
||||
const url = `${SEAFILE_URL}/api/v2.1/repos/${SEAFILE_NUGGETS_REPO_ID}/dir/?p=${encodeURIComponent(dir)}`
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return (data.dirent ?? []).filter(e => e.type === 'file').map(e => e.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) return
|
||||
|
||||
const [uploadFiles, seen] = await Promise.all([
|
||||
listSeafileDir(SEAFILE_EPISODE_UPLOAD_DIR),
|
||||
loadSeen(),
|
||||
])
|
||||
|
||||
const seenSet = new Set(seen.files)
|
||||
const newFiles = uploadFiles.filter(f => !seenSet.has(f))
|
||||
|
||||
if (newFiles.length === 0) return
|
||||
|
||||
for (const filename of newFiles) {
|
||||
console.log(`[nuggets-watcher] new direct upload detected: ${filename}`)
|
||||
await sendNuggetsUploadNotification({
|
||||
to: NUGGETS_NOTIFY_EMAIL,
|
||||
source: 'direct',
|
||||
filename,
|
||||
})
|
||||
seenSet.add(filename)
|
||||
}
|
||||
|
||||
seen.files = [...seenSet]
|
||||
await saveSeen(seen)
|
||||
}
|
||||
|
||||
export function startNuggetsSeafileWatcher() {
|
||||
if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) {
|
||||
console.log('[nuggets-watcher] Seafile not configured — watcher disabled.')
|
||||
return
|
||||
}
|
||||
// Initial poll after 2 min (let the server settle), then every 15 min
|
||||
setTimeout(() => {
|
||||
poll().catch(err => console.error('[nuggets-watcher] poll error:', err))
|
||||
setInterval(() => {
|
||||
poll().catch(err => console.error('[nuggets-watcher] poll error:', err))
|
||||
}, SEAFILE_POLL_INTERVAL_MS)
|
||||
}, 2 * 60 * 1000)
|
||||
|
||||
console.log(`[nuggets-watcher] started — polling every ${SEAFILE_POLL_INTERVAL_MS / 60000} min`)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFile, writeFile, unlink } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import multer from 'multer'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||
import { parseCookies, cookieSecureFlag } from '../helpers.js'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { sendNuggetsUploadNotification, sendNuggetsHostWelcomeEmail } from '../email.js'
|
||||
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
|
||||
import {
|
||||
NUGGETS_UPLOAD_HOSTS_FILE,
|
||||
NUGGETS_NOTIFY_EMAIL,
|
||||
SEAFILE_URL,
|
||||
SEAFILE_TOKEN,
|
||||
SEAFILE_NUGGETS_REPO_ID,
|
||||
SEAFILE_EPISODE_INFO_DIR,
|
||||
SEAFILE_EPISODE_UPLOAD_DIR,
|
||||
HOST_SESSION_COOKIE,
|
||||
HOST_SESSION_TTL_MS,
|
||||
DATA_DIR,
|
||||
} from '../config.js'
|
||||
|
||||
// ── In-memory host sessions ───────────────────────────────────────────────
|
||||
const hostSessions = new Map() // token → { hostId, hostName, expiresAt }
|
||||
|
||||
function createHostSession(hostId, hostName) {
|
||||
const token = randomUUID()
|
||||
hostSessions.set(token, { hostId, hostName, expiresAt: Date.now() + HOST_SESSION_TTL_MS })
|
||||
return token
|
||||
}
|
||||
|
||||
function getHostSession(req) {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies[HOST_SESSION_COOKIE]
|
||||
if (!token) return null
|
||||
const session = hostSessions.get(token)
|
||||
if (!session || session.expiresAt <= Date.now()) {
|
||||
hostSessions.delete(token)
|
||||
return null
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
function setHostSessionCookie(res, token) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${HOST_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(HOST_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||
)
|
||||
}
|
||||
|
||||
function clearHostSessionCookie(res) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${HOST_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||
)
|
||||
}
|
||||
|
||||
function requireHostAuth(req, res, next) {
|
||||
if (!getHostSession(req)) {
|
||||
res.status(401).json({ message: 'Not authenticated.' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// ── Host credentials data ─────────────────────────────────────────────────
|
||||
function hashPassword(pw) {
|
||||
return createHash('sha256').update(String(pw)).digest('hex')
|
||||
}
|
||||
|
||||
function passwordsMatch(plain, hash) {
|
||||
const a = Buffer.from(hashPassword(plain), 'utf8')
|
||||
const b = Buffer.from(hash, 'utf8')
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
async function loadUploadHosts() {
|
||||
try {
|
||||
const raw = await readFile(NUGGETS_UPLOAD_HOSTS_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return { hosts: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUploadHosts(data) {
|
||||
await writeFile(NUGGETS_UPLOAD_HOSTS_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
// ── Multer (disk storage, temp dir) ──────────────────────────────────────
|
||||
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
|
||||
|
||||
const upload = multer({
|
||||
dest: UPLOAD_TEMP_DIR,
|
||||
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
|
||||
})
|
||||
|
||||
// ── Seafile helpers ───────────────────────────────────────────────────────
|
||||
function seafileConfigured() {
|
||||
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
|
||||
}
|
||||
|
||||
async function getSeafileUploadLink(dir) {
|
||||
const url = `${SEAFILE_URL}/api/v2.1/repos/${SEAFILE_NUGGETS_REPO_ID}/upload-link/?path=${encodeURIComponent(dir)}`
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`Seafile upload-link failed (${res.status}): ${body}`)
|
||||
}
|
||||
// Seafile returns a quoted string: "https://..."
|
||||
const text = await res.text()
|
||||
return text.replace(/^"|"$/g, '')
|
||||
}
|
||||
|
||||
async function uploadToSeafile(uploadLink, fileBuffer, filename, parentDir) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', new Blob([fileBuffer]), filename)
|
||||
formData.append('parent_dir', parentDir)
|
||||
formData.append('filename', filename)
|
||||
formData.append('replace', '1')
|
||||
|
||||
const res = await fetch(uploadLink, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
|
||||
body: formData,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`Seafile upload failed (${res.status}): ${body}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ── Rate limiter ──────────────────────────────────────────────────────────
|
||||
const loginRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: 'Too many login attempts. Wait 15 minutes and try again.' },
|
||||
skipSuccessfulRequests: true,
|
||||
})
|
||||
|
||||
// ── Route registration ────────────────────────────────────────────────────
|
||||
export function register(app) {
|
||||
// ── Host auth ─────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/nuggets-upload/me', (req, res) => {
|
||||
const session = getHostSession(req)
|
||||
if (!session) {
|
||||
res.json({ authenticated: false })
|
||||
return
|
||||
}
|
||||
res.json({ authenticated: true, hostName: session.hostName, configured: seafileConfigured() })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/login', loginRateLimiter, async (req, res) => {
|
||||
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ message: 'Email and password are required.' })
|
||||
return
|
||||
}
|
||||
|
||||
const data = await loadUploadHosts()
|
||||
const host = data.hosts.find(h => h.email === email && h.active)
|
||||
if (!host || !passwordsMatch(password, host.passwordHash)) {
|
||||
res.status(401).json({ message: 'Invalid email or password.' })
|
||||
return
|
||||
}
|
||||
|
||||
const token = createHostSession(host.id, host.name)
|
||||
setHostSessionCookie(res, token)
|
||||
res.json({ ok: true, hostName: host.name })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/logout', (req, res) => {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies[HOST_SESSION_COOKIE]
|
||||
if (token) hostSessions.delete(token)
|
||||
clearHostSessionCookie(res)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── Episode upload (audio file + metadata) ───────────────────────────
|
||||
|
||||
app.post('/api/nuggets-upload/upload', requireHostAuth, upload.single('file'), async (req, res) => {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ message: 'No file received.' })
|
||||
return
|
||||
}
|
||||
|
||||
const session = getHostSession(req)
|
||||
const episodeName = typeof req.body?.episodeName === 'string' ? req.body.episodeName.trim() : ''
|
||||
const passage = typeof req.body?.passage === 'string' ? req.body.passage.trim() : ''
|
||||
const description = typeof req.body?.description === 'string' ? req.body.description.trim() : ''
|
||||
const originalName = req.file.originalname || req.file.filename
|
||||
const tempPath = req.file.path
|
||||
|
||||
if (!episodeName || !passage) {
|
||||
await unlink(tempPath).catch(() => {})
|
||||
res.status(400).json({ message: 'Episode name and passage are required.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!seafileConfigured()) {
|
||||
await unlink(tempPath).catch(() => {})
|
||||
res.status(503).json({ message: 'Seafile is not configured on this server. Contact the admin.' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { readFile: rf } = await import('node:fs/promises')
|
||||
const fileBuffer = await rf(tempPath)
|
||||
|
||||
// Upload audio to /Episode Upload/
|
||||
const audioLink = await getSeafileUploadLink(SEAFILE_EPISODE_UPLOAD_DIR)
|
||||
await uploadToSeafile(audioLink, fileBuffer, originalName, SEAFILE_EPISODE_UPLOAD_DIR)
|
||||
await unlink(tempPath).catch(() => {})
|
||||
|
||||
// Build and upload info file to /Episode Info/
|
||||
const now = new Date()
|
||||
const dateStr = now.toISOString().slice(0, 10)
|
||||
const infoContent = [
|
||||
`Episode: ${episodeName}`,
|
||||
`Host: ${session?.hostName ?? 'Unknown'}`,
|
||||
`Passage: ${passage}`,
|
||||
`Audio File: ${originalName}`,
|
||||
`Submitted: ${now.toUTCString()}`,
|
||||
'',
|
||||
'Description:',
|
||||
description || '(none)',
|
||||
].join('\n')
|
||||
|
||||
const safeEpName = episodeName.replace(/[^a-z0-9 _-]/gi, '').trim().replace(/\s+/g, '-').slice(0, 60)
|
||||
const infoFilename = `${dateStr}-${safeEpName}-info.txt`
|
||||
const infoBuffer = Buffer.from(infoContent, 'utf8')
|
||||
const infoLink = await getSeafileUploadLink(SEAFILE_EPISODE_INFO_DIR)
|
||||
await uploadToSeafile(infoLink, infoBuffer, infoFilename, SEAFILE_EPISODE_INFO_DIR)
|
||||
|
||||
console.log(`[nuggets-upload] ${session?.hostName ?? 'unknown'} uploaded "${episodeName}" (${originalName})`)
|
||||
|
||||
// Mark audio file as seen so the Seafile watcher doesn't double-notify
|
||||
await markSeafileFileSeen(originalName)
|
||||
|
||||
// Send admin notification (fire-and-forget)
|
||||
sendNuggetsUploadNotification({
|
||||
to: NUGGETS_NOTIFY_EMAIL,
|
||||
source: 'portal',
|
||||
hostName: session?.hostName ?? '',
|
||||
episodeName,
|
||||
passage,
|
||||
description,
|
||||
filename: originalName,
|
||||
infoFile: infoFilename,
|
||||
}).catch(err => console.error('[nuggets-upload] notify error:', err))
|
||||
|
||||
res.json({ ok: true, episodeName, filename: originalName, infoFile: infoFilename })
|
||||
} catch (err) {
|
||||
await unlink(tempPath).catch(() => {})
|
||||
console.error('[nuggets-upload] upload error:', err.message)
|
||||
res.status(500).json({ message: `Upload failed: ${err.message}` })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Admin: manage upload host accounts ───────────────────────────────
|
||||
|
||||
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
|
||||
const data = await loadUploadHosts()
|
||||
// Strip password hashes from the response
|
||||
const hosts = data.hosts.map(({ passwordHash: _ph, ...h }) => h)
|
||||
res.json({ hosts })
|
||||
})
|
||||
|
||||
app.post('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (req, res) => {
|
||||
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
|
||||
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
|
||||
if (!name || !email || !password) {
|
||||
res.status(400).json({ message: 'Name, email, and password are required.' })
|
||||
return
|
||||
}
|
||||
if (password.length < 8) {
|
||||
res.status(400).json({ message: 'Password must be at least 8 characters.' })
|
||||
return
|
||||
}
|
||||
|
||||
const data = await loadUploadHosts()
|
||||
if (data.hosts.some(h => h.email === email)) {
|
||||
res.status(409).json({ message: 'An account with that email already exists.' })
|
||||
return
|
||||
}
|
||||
|
||||
const host = {
|
||||
id: randomUUID(),
|
||||
name,
|
||||
email,
|
||||
passwordHash: hashPassword(password),
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
data.hosts.push(host)
|
||||
await saveUploadHosts(data)
|
||||
|
||||
sendNuggetsHostWelcomeEmail({
|
||||
to: email,
|
||||
name,
|
||||
username: email,
|
||||
password,
|
||||
portalUrl: `${process.env.SITE_URL ?? 'https://versebyversewithnate.us'}/host`,
|
||||
}).catch(err => console.error('[nuggets-host] welcome email error:', err))
|
||||
|
||||
const { passwordHash: _ph, ...safeHost } = host
|
||||
res.json({ ok: true, host: safeHost, emailSent: true })
|
||||
})
|
||||
|
||||
app.put('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
|
||||
const { id } = req.params
|
||||
const data = await loadUploadHosts()
|
||||
const idx = data.hosts.findIndex(h => h.id === id)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Host not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
const patch = {}
|
||||
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim()
|
||||
if (typeof req.body?.active === 'boolean') patch.active = req.body.active
|
||||
if (typeof req.body?.password === 'string' && req.body.password.length > 0) {
|
||||
if (req.body.password.length < 8) {
|
||||
res.status(400).json({ message: 'Password must be at least 8 characters.' })
|
||||
return
|
||||
}
|
||||
patch.passwordHash = hashPassword(req.body.password)
|
||||
}
|
||||
|
||||
const newPassword = typeof req.body?.password === 'string' && req.body.password.length > 0 ? req.body.password : null
|
||||
data.hosts[idx] = { ...data.hosts[idx], ...patch }
|
||||
await saveUploadHosts(data)
|
||||
|
||||
// Email the host their new password if they have an address on file
|
||||
if (newPassword && data.hosts[idx].email) {
|
||||
sendNuggetsHostWelcomeEmail({
|
||||
to: data.hosts[idx].email,
|
||||
name: data.hosts[idx].name,
|
||||
username: data.hosts[idx].username,
|
||||
password: newPassword,
|
||||
portalUrl: `${process.env.SITE_URL ?? 'https://versebyversewithnate.us'}/host`,
|
||||
}).catch(err => console.error('[nuggets-host] reset email error:', err))
|
||||
}
|
||||
|
||||
const { passwordHash: _ph, ...safeHost } = data.hosts[idx]
|
||||
res.json({ ok: true, host: safeHost, emailSent: Boolean(newPassword && data.hosts[idx].email) })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
|
||||
const { id } = req.params
|
||||
const data = await loadUploadHosts()
|
||||
const idx = data.hosts.findIndex(h => h.id === id)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Host not found.' })
|
||||
return
|
||||
}
|
||||
data.hosts.splice(idx, 1)
|
||||
await saveUploadHosts(data)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
+188
-1
@@ -666,7 +666,7 @@ type AdminView =
|
||||
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
||||
| 'contact:profile' | 'contact:copy'
|
||||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
|
||||
| 'nuggets:settings' | 'nuggets:hosts'
|
||||
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
||||
@@ -721,6 +721,7 @@ const ADMIN_NAV: NavGroup[] = [
|
||||
{ kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [
|
||||
{ kind: 'leaf', view: 'nuggets:settings', label: 'Show Settings' },
|
||||
{ kind: 'leaf', view: 'nuggets:hosts', label: 'Hosts' },
|
||||
{ kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' },
|
||||
]},
|
||||
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
|
||||
{ kind: 'leaf', view: 'downloads', label: 'Resources' },
|
||||
@@ -1095,6 +1096,189 @@ function QrCodeImage({ url, label }: { url: string; label: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Nuggets Upload Portal admin panel ─────────────────────────────────────
|
||||
interface UploadHost {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
active: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function NuggetsUploadPortalAdmin() {
|
||||
const [hosts, setHosts] = useState<UploadHost[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [addName, setAddName] = useState('')
|
||||
const [addEmail, setAddEmail] = useState('')
|
||||
const [addPassword, setAddPassword] = useState('')
|
||||
const [addError, setAddError] = useState('')
|
||||
const [addSaving, setAddSaving] = useState(false)
|
||||
const [addEmailSent, setAddEmailSent] = useState(false)
|
||||
const [resetId, setResetId] = useState<string | null>(null)
|
||||
const [resetPw, setResetPw] = useState('')
|
||||
const [resetError, setResetError] = useState('')
|
||||
const [resetSaving, setResetSaving] = useState(false)
|
||||
const [resetEmailSent, setResetEmailSent] = useState(false)
|
||||
|
||||
function reload() {
|
||||
setLoading(true)
|
||||
fetch('/api/admin/nuggets-upload/hosts')
|
||||
.then(r => r.json())
|
||||
.then(d => setHosts(d.hosts ?? []))
|
||||
.catch(() => setError('Failed to load hosts.'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { reload() }, [])
|
||||
|
||||
async function addHost(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setAddError('')
|
||||
setAddSaving(true)
|
||||
try {
|
||||
const r = await fetch('/api/admin/nuggets-upload/hosts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: addName, email: addEmail, password: addPassword }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setAddError(d.message ?? 'Failed.'); return }
|
||||
setAddEmailSent(Boolean(d.emailSent))
|
||||
setAddName(''); setAddEmail(''); setAddPassword('')
|
||||
reload()
|
||||
} finally {
|
||||
setAddSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(host: UploadHost) {
|
||||
await fetch(`/api/admin/nuggets-upload/hosts/${host.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ active: !host.active }),
|
||||
})
|
||||
reload()
|
||||
}
|
||||
|
||||
async function deleteHost(id: string) {
|
||||
if (!window.confirm('Remove this host account? This cannot be undone.')) return
|
||||
await fetch(`/api/admin/nuggets-upload/hosts/${id}`, { method: 'DELETE' })
|
||||
reload()
|
||||
}
|
||||
|
||||
async function resetPassword(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!resetId) return
|
||||
setResetError('')
|
||||
setResetSaving(true)
|
||||
try {
|
||||
const r = await fetch(`/api/admin/nuggets-upload/hosts/${resetId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: resetPw }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setResetError(d.message ?? 'Failed.'); return }
|
||||
setResetEmailSent(Boolean(d.emailSent))
|
||||
setResetId(null); setResetPw('')
|
||||
reload()
|
||||
} finally {
|
||||
setResetSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section" aria-label="Nuggets upload portal">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Nuggets — Upload Portal</h2>
|
||||
<p>
|
||||
Manage host login accounts for the upload portal at{' '}
|
||||
<a href="/host" target="_blank" rel="noopener noreferrer">/host</a>.
|
||||
Each host gets a username and password to upload episode files to Seafile.
|
||||
</p>
|
||||
<p style={{ fontSize: '0.82rem', color: '#888', marginTop: 4 }}>
|
||||
Make sure <code>SEAFILE_URL</code>, <code>SEAFILE_TOKEN</code>, and <code>SEAFILE_NUGGETS_REPO_ID</code> are set in your environment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
|
||||
{loading && <p className="admin-stats-note">Loading…</p>}
|
||||
|
||||
{!loading && hosts.length > 0 && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '1.5rem', fontSize: '0.88rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Name</th>
|
||||
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Email (login)</th>
|
||||
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Status</th>
|
||||
<th style={{ padding: '6px 8px' }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hosts.map(h => (
|
||||
<tr key={h.id} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
|
||||
<td style={{ padding: '7px 8px' }}>{h.name}</td>
|
||||
<td style={{ padding: '7px 8px', fontSize: '0.85rem' }}>{h.email}</td>
|
||||
<td style={{ padding: '7px 8px' }}>
|
||||
<span style={{ color: h.active ? '#27ae60' : '#888', fontWeight: 600 }}>{h.active ? 'Active' : 'Disabled'}</span>
|
||||
</td>
|
||||
<td style={{ padding: '7px 8px', whiteSpace: 'nowrap', textAlign: 'right' }}>
|
||||
<button className="btn-admin-ghost" style={{ fontSize: '0.78rem', marginRight: 6 }} onClick={() => toggleActive(h)}>
|
||||
{h.active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button className="btn-admin-ghost" style={{ fontSize: '0.78rem', marginRight: 6 }} onClick={() => { setResetId(h.id); setResetPw(''); setResetError('') }}>
|
||||
Reset PW
|
||||
</button>
|
||||
<button className="btn-admin-remove" style={{ fontSize: '0.78rem' }} onClick={() => deleteHost(h.id)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{!loading && hosts.length === 0 && <p className="admin-stats-note">No upload hosts yet. Add one below.</p>}
|
||||
|
||||
{resetId && (
|
||||
<form onSubmit={resetPassword} style={{ background: 'var(--color-surface-alt, #f7f5f0)', border: '1px solid var(--color-border, #e5e0d5)', borderRadius: 8, padding: '1rem', marginBottom: '1.5rem' }}>
|
||||
<p style={{ fontWeight: 600, marginBottom: 8 }}>Reset password for {hosts.find(h => h.id === resetId)?.username}</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="admin-field" style={{ flex: '1 1 200px' }}>
|
||||
<label>New Password (min 8 chars)</label>
|
||||
<input type="password" value={resetPw} onChange={e => setResetPw(e.target.value)} required minLength={8} />
|
||||
</div>
|
||||
<button className="btn-admin-save" type="submit" disabled={resetSaving}>{resetSaving ? 'Saving…' : 'Save'}</button>
|
||||
<button className="btn-admin-ghost" type="button" onClick={() => setResetId(null)}>Cancel</button>
|
||||
</div>
|
||||
{resetError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{resetError}</p>}
|
||||
</form>
|
||||
)}
|
||||
{resetEmailSent && !resetId && <p style={{ color: '#27ae60', fontSize: '0.85rem', marginBottom: 8 }}>✓ New password emailed to host.</p>}
|
||||
|
||||
<form onSubmit={addHost}>
|
||||
<h3 style={{ marginBottom: 10 }}>Add Host Account</h3>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div className="admin-field" style={{ flex: '1 1 160px' }}>
|
||||
<label>Display Name</label>
|
||||
<input type="text" placeholder="Jane Smith" value={addName} onChange={e => setAddName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="admin-field" style={{ flex: '1 1 200px' }}>
|
||||
<label>Email — used to log in</label>
|
||||
<input type="email" placeholder="jane@example.com" value={addEmail} onChange={e => setAddEmail(e.target.value)} required />
|
||||
</div>
|
||||
<div className="admin-field" style={{ flex: '1 1 150px' }}>
|
||||
<label>Password (min 8 chars)</label>
|
||||
<input type="password" value={addPassword} onChange={e => setAddPassword(e.target.value)} required minLength={8} />
|
||||
</div>
|
||||
<button className="btn-admin-save" type="submit" disabled={addSaving}>{addSaving ? 'Adding…' : '+ Add Host'}</button>
|
||||
</div>
|
||||
{addError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{addError}</p>}
|
||||
{addEmailSent && <p style={{ color: '#27ae60', marginTop: 6, fontSize: '0.85rem' }}>✓ Welcome email sent to host with login details.</p>}
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const normalizedContent = normalizeSiteContentForAdmin(content)
|
||||
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
||||
@@ -4717,6 +4901,9 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* NUGGETS UPLOAD PORTAL */}
|
||||
{adminView === 'nuggets:upload-portal' && <NuggetsUploadPortalAdmin />}
|
||||
|
||||
{/* STUDY COMMENTS */}
|
||||
{adminView === 'study-comments' && (() => {
|
||||
const filtered = studyComments.filter(c => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { usePageMeta } from './hooks/usePageMeta'
|
||||
import { useGlobalSearch } from './hooks/useGlobalSearch'
|
||||
import { GlobalSearch } from './components/GlobalSearch'
|
||||
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
|
||||
import { NuggetsHostPortal } from './components/NuggetsHostPortal'
|
||||
import { BIBLE_BOOKS } from '../shared/bible-books.js'
|
||||
import './App.css'
|
||||
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking } from './analytics'
|
||||
@@ -3139,6 +3140,7 @@ export default function App() {
|
||||
<Route path="/subscribe" element={<SubscribePage />} />
|
||||
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
||||
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
||||
<Route path="/host" element={<NuggetsHostPortal />} />
|
||||
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
||||
<Route path="/preview" element={<PreviewPage />} />
|
||||
<Route
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface SubmittedEpisode {
|
||||
episodeName: string
|
||||
filename: string
|
||||
at: string
|
||||
}
|
||||
|
||||
export function NuggetsHostPortal() {
|
||||
const [checking, setChecking] = useState(true)
|
||||
const [authenticated, setAuthenticated] = useState(false)
|
||||
const [hostName, setHostName] = useState('')
|
||||
const [seafileConfigured, setSeafileConfigured] = useState(true)
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loginError, setLoginError] = useState('')
|
||||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
|
||||
const [episodeName, setEpisodeName] = useState('')
|
||||
const [passage, setPassage] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [uploadError, setUploadError] = useState('')
|
||||
const [submitted, setSubmitted] = useState<SubmittedEpisode[]>([])
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/nuggets-upload/me')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
setAuthenticated(d.authenticated)
|
||||
setHostName(d.hostName ?? '')
|
||||
setSeafileConfigured(d.configured !== false)
|
||||
})
|
||||
.finally(() => setChecking(false))
|
||||
}, [])
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoginError('')
|
||||
setLoggingIn(true)
|
||||
try {
|
||||
const r = await fetch('/api/nuggets-upload/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setLoginError(d.message ?? 'Login failed.'); return }
|
||||
setHostName(d.hostName)
|
||||
setAuthenticated(true)
|
||||
} finally {
|
||||
setLoggingIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/nuggets-upload/logout', { method: 'POST' })
|
||||
setAuthenticated(false)
|
||||
setHostName('')
|
||||
setEmail('')
|
||||
setPassword('')
|
||||
setSubmitted([])
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEpisodeName('')
|
||||
setPassage('')
|
||||
setDescription('')
|
||||
setFile(null)
|
||||
setUploadError('')
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const dropped = e.dataTransfer.files[0]
|
||||
if (dropped) setFile(dropped)
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const picked = e.target.files?.[0]
|
||||
if (picked) setFile(picked)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!file) { setUploadError('Please select an audio file to upload.'); return }
|
||||
if (uploading) return
|
||||
|
||||
setUploadError('')
|
||||
setUploading(true)
|
||||
setProgress(0)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('episodeName', episodeName)
|
||||
formData.append('passage', passage)
|
||||
formData.append('description', description)
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', '/api/nuggets-upload/upload')
|
||||
|
||||
xhr.upload.addEventListener('progress', ev => {
|
||||
if (ev.lengthComputable) setProgress(Math.round((ev.loaded / ev.total) * 100))
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
setUploading(false)
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const d = JSON.parse(xhr.responseText)
|
||||
setSubmitted(prev => [{ episodeName: d.episodeName, filename: d.filename, at: new Date().toLocaleTimeString() }, ...prev])
|
||||
resetForm()
|
||||
} catch {
|
||||
setUploadError('Upload succeeded but response was unexpected.')
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
setUploadError(JSON.parse(xhr.responseText).message ?? 'Upload failed.')
|
||||
} catch {
|
||||
setUploadError('Upload failed.')
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
setUploading(false)
|
||||
setUploadError('Network error during upload.')
|
||||
resolve()
|
||||
})
|
||||
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={s.page}>
|
||||
<div style={s.card}>
|
||||
<p style={{ color: '#888', textAlign: 'center', margin: 0 }}>Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div style={s.page}>
|
||||
<div style={s.card}>
|
||||
<div style={s.logoRow}>
|
||||
<span style={s.logoEmoji}>🎙️</span>
|
||||
<span style={s.logoTitle}>Nuggets</span>
|
||||
</div>
|
||||
<h1 style={s.heading}>Host Upload Portal</h1>
|
||||
<p style={s.subText}>Sign in with your host credentials to submit an episode.</p>
|
||||
<form onSubmit={handleLogin} style={s.form}>
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="hp-u">Email</label>
|
||||
<input id="hp-u" style={s.input} type="email" autoComplete="email"
|
||||
value={email} onChange={e => setEmail(e.target.value)} disabled={loggingIn} required />
|
||||
</div>
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="hp-p">Password</label>
|
||||
<input id="hp-p" style={s.input} type="password" autoComplete="current-password"
|
||||
value={password} onChange={e => setPassword(e.target.value)} disabled={loggingIn} required />
|
||||
</div>
|
||||
{loginError && <p style={s.errorMsg}>{loginError}</p>}
|
||||
<button style={s.btnPrimary} type="submit" disabled={loggingIn}>
|
||||
{loggingIn ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={s.page}>
|
||||
<div style={{ ...s.card, maxWidth: 560 }}>
|
||||
{/* Header */}
|
||||
<div style={s.header}>
|
||||
<div style={s.logoRow}>
|
||||
<span style={s.logoEmoji}>🎙️</span>
|
||||
<span style={s.logoTitle}>Nuggets Upload Portal</span>
|
||||
</div>
|
||||
<div style={s.headerRight}>
|
||||
<span style={s.welcomeText}>{hostName}</span>
|
||||
<button style={s.btnGhost} onClick={handleLogout}>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!seafileConfigured && (
|
||||
<div style={s.warning}>
|
||||
Seafile is not configured yet — contact the admin before uploading.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} style={s.form}>
|
||||
{/* Episode Name */}
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="ep-name">Episode Name <span style={s.req}>*</span></label>
|
||||
<input
|
||||
id="ep-name"
|
||||
style={s.input}
|
||||
type="text"
|
||||
placeholder="e.g. The Grace of God in Romans 5"
|
||||
value={episodeName}
|
||||
onChange={e => setEpisodeName(e.target.value)}
|
||||
disabled={uploading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Passage */}
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="ep-passage">Passage <span style={s.req}>*</span></label>
|
||||
<input
|
||||
id="ep-passage"
|
||||
style={s.input}
|
||||
type="text"
|
||||
placeholder="e.g. Romans 5:1-11"
|
||||
value={passage}
|
||||
onChange={e => setPassage(e.target.value)}
|
||||
disabled={uploading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="ep-desc">Description</label>
|
||||
<textarea
|
||||
id="ep-desc"
|
||||
style={{ ...s.input, resize: 'vertical', minHeight: 80 }}
|
||||
placeholder="A short summary of what this episode covers…"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
disabled={uploading}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Audio File */}
|
||||
<div style={s.field}>
|
||||
<label style={s.label}>Audio File <span style={s.req}>*</span></label>
|
||||
<div
|
||||
style={{
|
||||
...s.dropZone,
|
||||
...(dragging ? s.dropZoneActive : {}),
|
||||
...(uploading ? s.dropZoneDisabled : {}),
|
||||
...(file ? s.dropZoneHasFile : {}),
|
||||
}}
|
||||
onDragOver={e => { e.preventDefault(); if (!uploading) setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.mp4,.mov"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{file ? (
|
||||
<div style={s.fileInfo}>
|
||||
<span style={s.fileIcon}>🎵</span>
|
||||
<div>
|
||||
<p style={s.fileName}>{file.name}</p>
|
||||
<p style={s.fileSize}>{(file.size / (1024 * 1024)).toFixed(1)} MB — click to change</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span style={s.dropIcon}>{dragging ? '📂' : '⬆️'}</span>
|
||||
<p style={s.dropLabel}>{dragging ? 'Drop to select' : 'Drop audio file here, or click to browse'}</p>
|
||||
<p style={s.dropSub}>MP3, WAV, M4A, FLAC, etc.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{uploading && (
|
||||
<div style={s.progressWrap}>
|
||||
<div style={s.progressBar}>
|
||||
<div style={{ ...s.progressFill, width: `${progress}%` }} />
|
||||
</div>
|
||||
<p style={s.progressLabel}>Uploading… {progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && <p style={s.errorMsg}>{uploadError}</p>}
|
||||
|
||||
<button style={{ ...s.btnPrimary, marginTop: 4 }} type="submit" disabled={uploading || !seafileConfigured}>
|
||||
{uploading ? 'Uploading…' : 'Submit Episode'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Submitted this session */}
|
||||
{submitted.length > 0 && (
|
||||
<div style={s.submittedList}>
|
||||
<p style={s.submittedTitle}>Submitted this session</p>
|
||||
{submitted.map((ep, i) => (
|
||||
<div key={i} style={s.submittedRow}>
|
||||
<span style={s.checkMark}>✓</span>
|
||||
<div>
|
||||
<p style={s.submittedEp}>{ep.episodeName}</p>
|
||||
<p style={s.submittedMeta}>{ep.filename} · {ep.at}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────────
|
||||
const s: Record<string, React.CSSProperties> = {
|
||||
page: {
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem 1rem',
|
||||
background: 'var(--color-bg, #f7f5f0)',
|
||||
},
|
||||
card: {
|
||||
background: 'var(--color-surface, #fff)',
|
||||
border: '1px solid var(--color-border, #e5e0d5)',
|
||||
borderRadius: 16,
|
||||
padding: '2.25rem 2rem',
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
|
||||
},
|
||||
logoRow: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 },
|
||||
logoEmoji: { fontSize: '1.5rem' },
|
||||
logoTitle: { fontWeight: 700, fontSize: '1rem' },
|
||||
heading: { fontSize: '1.4rem', fontWeight: 700, margin: '0 0 6px' },
|
||||
subText: { color: '#888', fontSize: '0.88rem', margin: '0 0 1.4rem' },
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
headerRight: { display: 'flex', alignItems: 'center', gap: 10, paddingTop: 2 },
|
||||
welcomeText: { fontSize: '0.85rem', color: '#888', fontWeight: 500 },
|
||||
btnGhost: {
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border, #e5e0d5)',
|
||||
borderRadius: 6,
|
||||
padding: '0.3rem 0.65rem',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
warning: {
|
||||
background: '#fff8e1',
|
||||
border: '1px solid #ffe082',
|
||||
borderRadius: 8,
|
||||
padding: '0.6rem 0.85rem',
|
||||
fontSize: '0.84rem',
|
||||
marginBottom: 16,
|
||||
color: '#7a5f00',
|
||||
},
|
||||
form: { display: 'flex', flexDirection: 'column', gap: 14 },
|
||||
field: { display: 'flex', flexDirection: 'column', gap: 5 },
|
||||
label: { fontSize: '0.85rem', fontWeight: 600 },
|
||||
req: { color: '#c0392b' },
|
||||
input: {
|
||||
padding: '0.55rem 0.75rem',
|
||||
border: '1px solid var(--color-border, #e5e0d5)',
|
||||
borderRadius: 8,
|
||||
fontSize: '0.93rem',
|
||||
background: 'var(--color-input-bg, #faf9f7)',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'inherit',
|
||||
},
|
||||
dropZone: {
|
||||
border: '2px dashed var(--color-border, #ddd8cc)',
|
||||
borderRadius: 10,
|
||||
padding: '1.4rem 1rem',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
userSelect: 'none',
|
||||
},
|
||||
dropZoneActive: { borderColor: '#c8a96e', background: 'rgba(200,169,110,0.06)' },
|
||||
dropZoneDisabled: { cursor: 'default', opacity: 0.65 },
|
||||
dropZoneHasFile: { borderColor: '#27ae60', background: 'rgba(39,174,96,0.04)' },
|
||||
dropIcon: { fontSize: '1.8rem', display: 'block', marginBottom: 6 },
|
||||
dropLabel: { fontWeight: 600, margin: '0 0 3px', fontSize: '0.9rem' },
|
||||
dropSub: { color: '#888', fontSize: '0.8rem', margin: 0 },
|
||||
fileInfo: { display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left' },
|
||||
fileIcon: { fontSize: '1.8rem', flexShrink: 0 },
|
||||
fileName: { fontWeight: 600, fontSize: '0.88rem', margin: '0 0 2px', wordBreak: 'break-all' },
|
||||
fileSize: { color: '#888', fontSize: '0.78rem', margin: 0 },
|
||||
progressWrap: { display: 'flex', flexDirection: 'column', gap: 5 },
|
||||
progressBar: { height: 7, background: '#e5e0d5', borderRadius: 4, overflow: 'hidden' },
|
||||
progressFill: { height: '100%', background: '#c8a96e', borderRadius: 4, transition: 'width 0.2s' },
|
||||
progressLabel: { fontSize: '0.82rem', color: '#888', margin: 0, textAlign: 'center' },
|
||||
errorMsg: { color: '#c0392b', fontSize: '0.84rem', margin: '2px 0' },
|
||||
btnPrimary: {
|
||||
background: '#c8a96e',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
padding: '0.7rem 1rem',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.95rem',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
},
|
||||
submittedList: {
|
||||
marginTop: 20,
|
||||
border: '1px solid var(--color-border, #e5e0d5)',
|
||||
borderRadius: 10,
|
||||
padding: '0.75rem 1rem',
|
||||
},
|
||||
submittedTitle: {
|
||||
fontWeight: 600,
|
||||
fontSize: '0.78rem',
|
||||
color: '#888',
|
||||
margin: '0 0 8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
},
|
||||
submittedRow: { display: 'flex', alignItems: 'flex-start', gap: 10, padding: '5px 0' },
|
||||
checkMark: { color: '#27ae60', fontWeight: 700, flexShrink: 0 },
|
||||
submittedEp: { fontWeight: 600, fontSize: '0.88rem', margin: '0 0 2px' },
|
||||
submittedMeta: { color: '#888', fontSize: '0.78rem', margin: 0 },
|
||||
}
|
||||
Reference in new issue
Block a user