Initial
This commit is contained in:
27
.env.example
Normal file
27
.env.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
APP_BASE_URL=http://localhost:3000
|
||||||
|
PRIMARY_DOMAIN=debbiewindlerseamstress.com
|
||||||
|
SECONDARY_DOMAIN=debbiewindler.com
|
||||||
|
|
||||||
|
# Generate a long random value before production.
|
||||||
|
SESSION_SECRET=replace-with-a-long-random-secret
|
||||||
|
|
||||||
|
# SQLite and local file storage.
|
||||||
|
DATABASE_PATH=./storage/site.sqlite
|
||||||
|
UPLOAD_DIR=./public/uploads
|
||||||
|
BACKUP_DIR=./storage/backups
|
||||||
|
|
||||||
|
# SMTP email notification settings. Messages are still stored if email fails.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM="Debbie Windler Seamstress <website@debbiewindlerseamstress.com>"
|
||||||
|
OWNER_EMAIL=
|
||||||
|
|
||||||
|
# Optional future CAPTCHA support.
|
||||||
|
CAPTCHA_ENABLED=false
|
||||||
|
CAPTCHA_SITE_KEY=
|
||||||
|
CAPTCHA_SECRET_KEY=
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
storage/*.sqlite
|
||||||
|
storage/*.sqlite-shm
|
||||||
|
storage/*.sqlite-wal
|
||||||
|
storage/backups/*.zip
|
||||||
|
public/uploads/*
|
||||||
|
!public/uploads/.gitkeep
|
||||||
|
!public/uploads/sample-hero.png
|
||||||
|
npm-debug.log*
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:24-bookworm-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN mkdir -p storage/backups public/uploads
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "--no-warnings", "src/server.js"]
|
||||||
216
README.md
Normal file
216
README.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# Debbie Windler Seamstress Website
|
||||||
|
|
||||||
|
Self-hosted website and owner administration system for `debbiewindlerseamstress.com`.
|
||||||
|
|
||||||
|
This project is intentionally a small, understandable Node.js application with Express, SQLite, session login, local image storage, Docker support, backups, and a nontechnical owner backend. It does not include checkout, customer accounts, public comments, customer uploads, payments, or scheduling.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
- Four public pages: Home, Items, Services, About and Contact.
|
||||||
|
- Full-screen editable hero with replaceable background image, overlay, position, text, and buttons.
|
||||||
|
- Editable featured/new/sale item showcase.
|
||||||
|
- Alphabetical item browsing with search and filters.
|
||||||
|
- Editable services page.
|
||||||
|
- Editable about text and equipment list.
|
||||||
|
- Website contact form that stores messages in SQLite even when email notification is not configured or fails.
|
||||||
|
- Secure first-owner setup flow at `/admin/setup`.
|
||||||
|
- Owner login at `/admin`.
|
||||||
|
- Admin dashboard, page editor, item manager, service manager, about/equipment manager, messages inbox, media library, appearance settings, navigation settings, business settings, search/sharing settings, security settings, backups, and maintenance mode.
|
||||||
|
- Local image upload validation and WebP optimization through Sharp.
|
||||||
|
- Manual downloadable backup archives for the database and uploaded images.
|
||||||
|
- Docker and Docker Compose support.
|
||||||
|
- Sample content and one sample sewing-themed hero image that should be replaced with Debbie's real artwork/photos.
|
||||||
|
|
||||||
|
## Project Location
|
||||||
|
|
||||||
|
`C:\Users\Owner\Documents\UO private Server\DebbieWindlerSeamstress`
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
Install Node.js 24 or newer, then from this folder:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm install
|
||||||
|
pnpm run init-db
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
If `pnpm` is not installed globally, `npm install` and `npm run dev` also work.
|
||||||
|
|
||||||
|
## First Owner Setup
|
||||||
|
|
||||||
|
1. Start the site.
|
||||||
|
2. Open `http://localhost:3000/admin`.
|
||||||
|
3. The app will redirect to `/admin/setup` until the first owner account is created.
|
||||||
|
4. Enter owner name, email, and a password with at least 10 characters.
|
||||||
|
5. After setup, future visits to `/admin` use the owner login screen.
|
||||||
|
|
||||||
|
No administrator password is hardcoded or stored as plain text.
|
||||||
|
|
||||||
|
## Common Owner Tasks
|
||||||
|
|
||||||
|
Edit the home page:
|
||||||
|
|
||||||
|
1. Log in at `/admin`.
|
||||||
|
2. Choose `Home Page`.
|
||||||
|
3. Change hero text, background URL, overlay, buttons, and showcase text.
|
||||||
|
4. Use `Preview Home Page`.
|
||||||
|
|
||||||
|
Add an item:
|
||||||
|
|
||||||
|
1. Choose `Items`.
|
||||||
|
2. Choose `Add Item`.
|
||||||
|
3. Fill in name, descriptions, category, price notes, availability, badges, and publish setting.
|
||||||
|
4. Upload one or more item images.
|
||||||
|
5. Save.
|
||||||
|
|
||||||
|
Read a customer message:
|
||||||
|
|
||||||
|
1. Choose `Messages`.
|
||||||
|
2. Open a message.
|
||||||
|
3. Use `Reply by Email` to open the computer's normal email program.
|
||||||
|
4. Mark unread, archive, or delete as needed.
|
||||||
|
|
||||||
|
Create a backup:
|
||||||
|
|
||||||
|
1. Choose `Backups`.
|
||||||
|
2. Choose `Create Manual Backup`.
|
||||||
|
3. Download the created ZIP file.
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` for local or production configuration.
|
||||||
|
|
||||||
|
Important values:
|
||||||
|
|
||||||
|
- `PORT`: app port, default `3000`.
|
||||||
|
- `APP_BASE_URL`: final public URL, for example `https://debbiewindlerseamstress.com`.
|
||||||
|
- `SESSION_SECRET`: long random secret for sessions.
|
||||||
|
- `DATABASE_PATH`: SQLite file path.
|
||||||
|
- `UPLOAD_DIR`: uploaded image folder.
|
||||||
|
- `BACKUP_DIR`: backup output folder.
|
||||||
|
- `OWNER_EMAIL`: where contact form notifications go.
|
||||||
|
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`: email notification settings.
|
||||||
|
|
||||||
|
Messages are always stored in the database even when SMTP is empty or fails.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Create `.env`, then run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The app stores persistent files in:
|
||||||
|
|
||||||
|
- `./storage`
|
||||||
|
- `./public/uploads`
|
||||||
|
|
||||||
|
Do not include `.env` in downloadable backups or source control.
|
||||||
|
|
||||||
|
## Production Hosting Notes
|
||||||
|
|
||||||
|
Recommended production shape:
|
||||||
|
|
||||||
|
1. Run the app with Docker Compose on Perry's server.
|
||||||
|
2. Put a reverse proxy such as Caddy, Nginx, or IIS ARR in front of it.
|
||||||
|
3. Configure TLS for `debbiewindlerseamstress.com`.
|
||||||
|
4. Later, redirect `debbiewindler.com` to `debbiewindlerseamstress.com`.
|
||||||
|
5. Set `NODE_ENV=production`, `APP_BASE_URL`, `SESSION_SECRET`, SMTP values, and owner email in `.env`.
|
||||||
|
|
||||||
|
This project does not make DNS, router, firewall, reverse proxy, or live production changes.
|
||||||
|
|
||||||
|
## Backup and Restore
|
||||||
|
|
||||||
|
Manual backups are created from `/admin/backups`.
|
||||||
|
|
||||||
|
Backups include:
|
||||||
|
|
||||||
|
- SQLite database.
|
||||||
|
- Uploaded images.
|
||||||
|
|
||||||
|
Backups exclude:
|
||||||
|
|
||||||
|
- `.env`.
|
||||||
|
- passwords and SMTP secrets outside the database.
|
||||||
|
|
||||||
|
Restore process:
|
||||||
|
|
||||||
|
1. Stop the app.
|
||||||
|
2. Make a copy of the current `storage` and `public/uploads` folders.
|
||||||
|
3. Extract the backup ZIP.
|
||||||
|
4. Replace `storage/site.sqlite` and restore the `uploads` folder.
|
||||||
|
5. Start the app and check `/healthz`.
|
||||||
|
|
||||||
|
Use strong confirmation before replacing production files.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Current smoke tests verify:
|
||||||
|
|
||||||
|
- Public pages load.
|
||||||
|
- Admin redirects to setup/login.
|
||||||
|
- Contact form validation and CSRF session behavior.
|
||||||
|
|
||||||
|
Manual checklist before going live:
|
||||||
|
|
||||||
|
- Create owner account.
|
||||||
|
- Log in and log out.
|
||||||
|
- Edit the home hero and preview.
|
||||||
|
- Upload an image and confirm it appears in the media library.
|
||||||
|
- Add an item and confirm `/items` stays alphabetical.
|
||||||
|
- Add a service and reorder it.
|
||||||
|
- Edit About and Equipment entries.
|
||||||
|
- Submit a contact form message.
|
||||||
|
- Confirm the message appears in the admin inbox.
|
||||||
|
- Configure SMTP and confirm email notifications.
|
||||||
|
- Confirm messages are still stored if SMTP is disabled.
|
||||||
|
- Create and download a backup.
|
||||||
|
- Enable maintenance mode and confirm admins can still log in.
|
||||||
|
- Check phone, tablet, and desktop widths.
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
- Set a long `SESSION_SECRET`.
|
||||||
|
- Use HTTPS in production.
|
||||||
|
- Keep `.env` private.
|
||||||
|
- Use a strong owner password.
|
||||||
|
- Keep Docker base images and npm packages updated.
|
||||||
|
- Back up before updates.
|
||||||
|
- Do not expose `public/uploads` as executable content.
|
||||||
|
- Do not add customer file uploads without a separate security review.
|
||||||
|
- Keep private home address hidden unless Debbie deliberately chooses to publish it.
|
||||||
|
|
||||||
|
## Still Needed From Owner
|
||||||
|
|
||||||
|
- Final Debbie business wording.
|
||||||
|
- Final email address and phone number.
|
||||||
|
- Facebook page link, if desired.
|
||||||
|
- Service area and business hours.
|
||||||
|
- Final photos, artwork, logo, favicon, and business card artwork.
|
||||||
|
- Real service descriptions and pricing notes.
|
||||||
|
- Real item listings and item photos.
|
||||||
|
- SMTP provider settings.
|
||||||
|
- Decision on whether `debbiewindler.com` should redirect at the reverse proxy or DNS/hosting layer later.
|
||||||
|
|
||||||
|
## Notes About Initial Version
|
||||||
|
|
||||||
|
This is a complete working first version, but a few advanced features are intentionally conservative:
|
||||||
|
|
||||||
|
- CAPTCHA is wired as a future setting but not enabled by default.
|
||||||
|
- Revision history is stored for important edits; a polished one-click restore screen can be expanded later.
|
||||||
|
- The page editor uses controlled section layouts instead of arbitrary code or drag-and-drop.
|
||||||
|
- The owner reply flow uses `mailto:` instead of a built-in outgoing email client.
|
||||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
debbie-windler-seamstress:
|
||||||
|
build: .
|
||||||
|
container_name: debbie-windler-seamstress
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3000}:3000"
|
||||||
|
volumes:
|
||||||
|
- ./storage:/app/storage
|
||||||
|
- ./public/uploads:/app/public/uploads
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "--no-warnings", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "debbie-windler-seamstress",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Self-hosted website and owner administration system for Debbie Windler Seamstress.",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node --no-warnings src/server.js",
|
||||||
|
"dev": "node --no-warnings src/server.js",
|
||||||
|
"init-db": "node --no-warnings src/database.js",
|
||||||
|
"backup": "node --no-warnings src/backup.js",
|
||||||
|
"test": "node --no-warnings --test"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"archiver": "^7.0.1",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"multer": "^2.2.0",
|
||||||
|
"nodemailer": "^6.9.14",
|
||||||
|
"sanitize-html": "^2.13.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"slugify": "^1.6.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"supertest": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1930
pnpm-lock.yaml
generated
Normal file
1930
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
pnpm-workspace.yaml
Normal file
5
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
allowBuilds:
|
||||||
|
better-sqlite3: set this to true or false
|
||||||
|
sharp: set this to true or false
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- sharp
|
||||||
216
public/css/admin.css
Normal file
216
public/css/admin.css
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: #2f2926;
|
||||||
|
background: #f6f0ea;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
a { color: #7d3f45; }
|
||||||
|
button, .button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #7d3f45;
|
||||||
|
color: white;
|
||||||
|
padding: .72rem .95rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
.button.secondary, .link-button {
|
||||||
|
background: transparent;
|
||||||
|
color: #7d3f45;
|
||||||
|
box-shadow: inset 0 0 0 1px #7d3f45;
|
||||||
|
}
|
||||||
|
.danger { background: #9b2f2f; }
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(47,41,38,.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .7rem .8rem;
|
||||||
|
font: inherit;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
label { display: grid; gap: .35rem; font-weight: 800; }
|
||||||
|
.checkbox { display: flex; gap: .55rem; align-items: flex-start; }
|
||||||
|
.checkbox input { width: auto; margin-top: .35rem; }
|
||||||
|
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||||
|
outline: 3px solid #7a8f73;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -100px;
|
||||||
|
left: 1rem;
|
||||||
|
background: #7d3f45;
|
||||||
|
color: white;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.skip-link:focus { top: 1rem; }
|
||||||
|
.admin-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #2f2926;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.admin-sidebar a { color: white; }
|
||||||
|
.admin-brand {
|
||||||
|
display: block;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.admin-brand span { font-family: Arial, Helvetica, sans-serif; font-size: .9rem; opacity: .8; }
|
||||||
|
.admin-sidebar nav {
|
||||||
|
display: grid;
|
||||||
|
gap: .25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.admin-sidebar nav a, .admin-sidebar > a, .link-button {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .58rem .7rem;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.admin-sidebar nav a.active { background: rgba(255,255,255,.16); }
|
||||||
|
.admin-main {
|
||||||
|
margin-left: 260px;
|
||||||
|
padding: clamp(1rem, 4vw, 2rem);
|
||||||
|
}
|
||||||
|
.admin-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
color: #7a8f73;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: .75rem;
|
||||||
|
}
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
line-height: 1.12;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
h1 { margin: .2rem 0; font-size: clamp(2rem, 5vw, 3.5rem); }
|
||||||
|
.panel, .table-list article, .stats-grid article, .auth-card, .section-editor {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid rgba(47,41,38,.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.stats-grid strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: #7d3f45;
|
||||||
|
}
|
||||||
|
.quick-actions, .toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.two-column {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.table-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.editor-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 940px;
|
||||||
|
}
|
||||||
|
.editor-form.nested {
|
||||||
|
margin-top: 1rem;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.inline-form, .inline-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: .75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.inline-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.section-editor {
|
||||||
|
margin: .75rem 0;
|
||||||
|
}
|
||||||
|
.section-editor summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.media-card img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #efe3da;
|
||||||
|
}
|
||||||
|
.media-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .75rem;
|
||||||
|
display: grid;
|
||||||
|
gap: .4rem;
|
||||||
|
}
|
||||||
|
.message-detail pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: #f6f0ea;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.auth-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
width: min(100%, 460px);
|
||||||
|
}
|
||||||
|
.auth-card form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.admin-sidebar {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.admin-main {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.admin-sidebar nav {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
320
public/css/site.css
Normal file
320
public/css/site.css
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: var(--body-font);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
img { max-width: 100%; display: block; }
|
||||||
|
a { color: var(--link); }
|
||||||
|
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||||
|
outline: 3px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -100px;
|
||||||
|
left: 1rem;
|
||||||
|
background: var(--button);
|
||||||
|
color: var(--button-text);
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.skip-link:focus { top: 1rem; }
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: .85rem clamp(1rem, 4vw, 2.5rem);
|
||||||
|
background: color-mix(in srgb, var(--bg) 94%, white);
|
||||||
|
border-bottom: 1px solid rgba(52,43,40,.12);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .65rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--heading);
|
||||||
|
font-family: var(--heading-font);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent 47%, var(--accent) 48% 52%, transparent 53%),
|
||||||
|
radial-gradient(circle at 50% 50%, var(--bg-alt) 0 38%, transparent 39%);
|
||||||
|
}
|
||||||
|
.site-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.site-nav a, .site-footer nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
padding: .5rem .7rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.site-nav a.active { background: var(--bg-alt); }
|
||||||
|
.nav-cta, .button, button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--button);
|
||||||
|
color: var(--button-text);
|
||||||
|
padding: .78rem 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.button.secondary, .site-nav .nav-cta.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--button);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--button);
|
||||||
|
}
|
||||||
|
.button.small { padding: .55rem .7rem; font-size: .92rem; }
|
||||||
|
.text-button { font-weight: 700; }
|
||||||
|
.menu-toggle { display: none; }
|
||||||
|
.hero {
|
||||||
|
min-height: min(760px, 92vh);
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
padding: clamp(4rem, 10vw, 8rem) clamp(1rem, 5vw, 4rem);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.hero::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255,250,245,var(--overlay-opacity));
|
||||||
|
}
|
||||||
|
.hero.dark::before { background: rgba(0,0,0,var(--overlay-opacity)); }
|
||||||
|
.hero.none::before { opacity: 0; }
|
||||||
|
.hero-inner {
|
||||||
|
position: relative;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-family: var(--heading-font);
|
||||||
|
color: var(--heading);
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
h1 { font-size: clamp(2.4rem, 8vw, 5.8rem); margin: .25rem 0 1rem; }
|
||||||
|
h2 { font-size: clamp(1.8rem, 4vw, 3rem); }
|
||||||
|
h3 { font-size: 1.35rem; }
|
||||||
|
.hero p { font-size: clamp(1.05rem, 2vw, 1.32rem); max-width: 58ch; }
|
||||||
|
.hero-contact { font-weight: 700; }
|
||||||
|
.button-row { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 1.5rem; }
|
||||||
|
.showcase, .content-section, .filters, .cards-grid, .detail-layout, .gallery, .equipment, .contact-details, .contact-form-section {
|
||||||
|
padding: clamp(2.5rem, 6vw, 5rem) clamp(1rem, 5vw, 3rem);
|
||||||
|
}
|
||||||
|
.section-heading { max-width: var(--site-width); margin: 0 auto 1.5rem; }
|
||||||
|
.section-heading p { max-width: 68ch; }
|
||||||
|
.cards-grid {
|
||||||
|
max-width: var(--site-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.item-card, .service-card {
|
||||||
|
background: rgba(255,255,255,.72);
|
||||||
|
border: 1px solid rgba(52,43,40,.12);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.item-card img, .service-card img, .image-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(45deg, rgba(122,143,115,.18) 0 9px, rgba(125,63,69,.12) 9px 18px),
|
||||||
|
var(--bg-alt);
|
||||||
|
}
|
||||||
|
.image-placeholder.thread {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 35%, rgba(125,63,69,.25) 0 16%, transparent 17%),
|
||||||
|
radial-gradient(circle at 70% 60%, rgba(122,143,115,.28) 0 18%, transparent 19%),
|
||||||
|
var(--bg-alt);
|
||||||
|
}
|
||||||
|
.card-body { padding: 1rem; }
|
||||||
|
.badges { display: flex; flex-wrap: wrap; gap: .35rem; min-height: 1.7rem; }
|
||||||
|
.badges span, .status {
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-alt);
|
||||||
|
padding: .2rem .55rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.price, .pricing-note { font-weight: 800; color: var(--button); }
|
||||||
|
.card-actions { display: flex; flex-wrap: wrap; gap: .7rem; align-items: center; }
|
||||||
|
.content-section {
|
||||||
|
max-width: var(--site-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.content-section.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
|
||||||
|
gap: clamp(1rem, 4vw, 3rem);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.content-section.split.reverse { grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); }
|
||||||
|
.content-section img, .detail-image, .gallery img {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid rgba(52,43,40,.12);
|
||||||
|
}
|
||||||
|
.contact-callout {
|
||||||
|
background: var(--button);
|
||||||
|
color: var(--button-text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.contact-callout h2 { color: var(--button-text); }
|
||||||
|
.page-hero {
|
||||||
|
padding: clamp(3rem, 8vw, 6rem) clamp(1rem, 5vw, 3rem);
|
||||||
|
background: var(--bg-alt);
|
||||||
|
}
|
||||||
|
.page-hero > * { max-width: var(--site-width); margin-left: auto; margin-right: auto; }
|
||||||
|
.page-hero.compact h1 { font-size: clamp(2.1rem, 6vw, 4.2rem); }
|
||||||
|
.filters form {
|
||||||
|
max-width: var(--site-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
label { display: grid; gap: .35rem; font-weight: 700; }
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(52,43,40,.24);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .75rem .85rem;
|
||||||
|
font: inherit;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.alphabet {
|
||||||
|
max-width: var(--site-width);
|
||||||
|
margin: 1rem auto 0;
|
||||||
|
display: flex;
|
||||||
|
gap: .3rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.alphabet a {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .35rem .55rem;
|
||||||
|
}
|
||||||
|
.detail-layout {
|
||||||
|
max-width: var(--site-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, .9fr) minmax(0, 1.1fr);
|
||||||
|
gap: clamp(1.5rem, 5vw, 4rem);
|
||||||
|
}
|
||||||
|
.gallery {
|
||||||
|
max-width: var(--site-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.contact-details {
|
||||||
|
max-width: var(--site-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.contact-details dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
.contact-details div {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(52,43,40,.12);
|
||||||
|
}
|
||||||
|
dt { font-weight: 800; }
|
||||||
|
dd { margin: .25rem 0 0; }
|
||||||
|
.contact-form-section {
|
||||||
|
background: var(--bg-alt);
|
||||||
|
}
|
||||||
|
.contact-form {
|
||||||
|
max-width: 780px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.checkbox { display: flex; align-items: flex-start; gap: .6rem; }
|
||||||
|
.checkbox input { width: auto; margin-top: .35rem; }
|
||||||
|
.hp { position: absolute; left: -9999px; }
|
||||||
|
.notice.success {
|
||||||
|
background: #e7f2e4;
|
||||||
|
border: 1px solid #9abb91;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.site-footer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 2rem clamp(1rem, 5vw, 3rem);
|
||||||
|
border-top: 1px solid rgba(52,43,40,.12);
|
||||||
|
background: color-mix(in srgb, var(--bg-alt) 80%, white);
|
||||||
|
}
|
||||||
|
.site-footer nav { display: grid; align-content: start; }
|
||||||
|
.copyright { grid-column: 1 / -1; font-size: .9rem; }
|
||||||
|
.maintenance {
|
||||||
|
min-height: 70vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
.maintenance img { max-width: 360px; border-radius: var(--radius); }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.menu-toggle { display: inline-flex; }
|
||||||
|
.site-header { align-items: flex-start; }
|
||||||
|
.site-nav {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: .75rem;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
border-bottom: 1px solid rgba(52,43,40,.12);
|
||||||
|
}
|
||||||
|
.site-nav.open { display: flex; }
|
||||||
|
.content-section.split, .content-section.split.reverse, .detail-layout, .site-footer {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.hero { min-height: 82vh; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
|
||||||
|
}
|
||||||
25
public/js/admin.js
Normal file
25
public/js/admin.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
document.querySelectorAll("form").forEach((form) => {
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
const danger = event.submitter?.classList.contains("danger");
|
||||||
|
if (danger && !confirm("Please confirm this permanent action.")) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const button = event.submitter;
|
||||||
|
if (button && button.tagName === "BUTTON") {
|
||||||
|
button.dataset.originalText = button.textContent;
|
||||||
|
button.textContent = "Saving...";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("input[type='file']").forEach((input) => {
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
const existing = input.parentElement.querySelector(".file-hint");
|
||||||
|
existing?.remove();
|
||||||
|
const hint = document.createElement("small");
|
||||||
|
hint.className = "file-hint";
|
||||||
|
hint.textContent = `${input.files.length} selected`;
|
||||||
|
input.parentElement.appendChild(hint);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
public/js/site.js
Normal file
19
public/js/site.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const toggle = event.target.closest("[data-menu-toggle]");
|
||||||
|
if (!toggle) return;
|
||||||
|
const menu = document.getElementById(toggle.getAttribute("aria-controls"));
|
||||||
|
const open = toggle.getAttribute("aria-expanded") === "true";
|
||||||
|
toggle.setAttribute("aria-expanded", String(!open));
|
||||||
|
menu?.classList.toggle("open", !open);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("form").forEach((form) => {
|
||||||
|
form.addEventListener("submit", () => {
|
||||||
|
const button = form.querySelector("button[type='submit'], button:not([type])");
|
||||||
|
if (button && !button.dataset.keepEnabled) {
|
||||||
|
button.dataset.originalText = button.textContent;
|
||||||
|
button.textContent = "Sending...";
|
||||||
|
button.disabled = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
1
public/uploads/.gitkeep
Normal file
1
public/uploads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
public/uploads/sample-hero.png
Normal file
BIN
public/uploads/sample-hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
11
src/backup.js
Normal file
11
src/backup.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
const { createBackup } = require("./server");
|
||||||
|
|
||||||
|
createBackup()
|
||||||
|
.then((backup) => {
|
||||||
|
console.log(`Backup created: ${backup.file_path}`);
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
41
src/config.js
Normal file
41
src/config.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function bool(value, fallback = false) {
|
||||||
|
if (value === undefined || value === null || value === "") return fallback;
|
||||||
|
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRoot(value, fallback) {
|
||||||
|
const selected = value || fallback;
|
||||||
|
return path.isAbsolute(selected) ? selected : path.join(root, selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
root,
|
||||||
|
env: process.env.NODE_ENV || "development",
|
||||||
|
port: Number(process.env.PORT || 3000),
|
||||||
|
baseUrl: process.env.APP_BASE_URL || "http://localhost:3000",
|
||||||
|
primaryDomain: process.env.PRIMARY_DOMAIN || "debbiewindlerseamstress.com",
|
||||||
|
secondaryDomain: process.env.SECONDARY_DOMAIN || "debbiewindler.com",
|
||||||
|
sessionSecret: process.env.SESSION_SECRET || "development-only-change-me",
|
||||||
|
databasePath: fromRoot(process.env.DATABASE_PATH, "./storage/site.sqlite"),
|
||||||
|
uploadDir: fromRoot(process.env.UPLOAD_DIR, "./public/uploads"),
|
||||||
|
backupDir: fromRoot(process.env.BACKUP_DIR, "./storage/backups"),
|
||||||
|
smtp: {
|
||||||
|
host: process.env.SMTP_HOST || "",
|
||||||
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
|
secure: bool(process.env.SMTP_SECURE, false),
|
||||||
|
user: process.env.SMTP_USER || "",
|
||||||
|
pass: process.env.SMTP_PASS || "",
|
||||||
|
from: process.env.SMTP_FROM || "Debbie Windler Seamstress <website@debbiewindlerseamstress.com>",
|
||||||
|
ownerEmail: process.env.OWNER_EMAIL || ""
|
||||||
|
},
|
||||||
|
captcha: {
|
||||||
|
enabled: bool(process.env.CAPTCHA_ENABLED, false),
|
||||||
|
siteKey: process.env.CAPTCHA_SITE_KEY || "",
|
||||||
|
secretKey: process.env.CAPTCHA_SECRET_KEY || ""
|
||||||
|
},
|
||||||
|
production: process.env.NODE_ENV === "production"
|
||||||
|
};
|
||||||
471
src/database.js
Normal file
471
src/database.js
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { DatabaseSync } = require("node:sqlite");
|
||||||
|
const config = require("./config");
|
||||||
|
const { now, makeSlug } = require("./utils");
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(config.databasePath), { recursive: true });
|
||||||
|
fs.mkdirSync(config.uploadDir, { recursive: true });
|
||||||
|
fs.mkdirSync(config.backupDir, { recursive: true });
|
||||||
|
|
||||||
|
const db = new DatabaseSync(config.databasePath);
|
||||||
|
db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
function migrate() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS administrators (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'owner',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
last_login_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
sid TEXT PRIMARY KEY,
|
||||||
|
sess TEXT NOT NULL,
|
||||||
|
expired_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
subtitle TEXT,
|
||||||
|
intro TEXT,
|
||||||
|
header_image_id INTEGER,
|
||||||
|
background_image_id INTEGER,
|
||||||
|
meta_title TEXT,
|
||||||
|
meta_description TEXT,
|
||||||
|
og_title TEXT,
|
||||||
|
og_description TEXT,
|
||||||
|
og_image_id INTEGER,
|
||||||
|
canonical_url TEXT,
|
||||||
|
search_visible INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'published',
|
||||||
|
draft_json TEXT,
|
||||||
|
published_at TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(header_image_id) REFERENCES media(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY(background_image_id) REFERENCES media(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY(og_image_id) REFERENCES media(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS page_sections (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id INTEGER NOT NULL,
|
||||||
|
section_key TEXT,
|
||||||
|
title TEXT,
|
||||||
|
body TEXT,
|
||||||
|
layout TEXT NOT NULL DEFAULT 'text',
|
||||||
|
image_id INTEGER,
|
||||||
|
background_image_id INTEGER,
|
||||||
|
background_color TEXT,
|
||||||
|
button_label TEXT,
|
||||||
|
button_url TEXT,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_published INTEGER NOT NULL DEFAULT 1,
|
||||||
|
data_json TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(page_id) REFERENCES pages(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(image_id) REFERENCES media(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY(background_image_id) REFERENCES media(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS media (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
alt_text TEXT,
|
||||||
|
category TEXT,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
thumb_url TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
slug TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
short_description TEXT,
|
||||||
|
full_description TEXT,
|
||||||
|
category_id INTEGER,
|
||||||
|
main_image_id INTEGER,
|
||||||
|
price TEXT,
|
||||||
|
starting_price TEXT,
|
||||||
|
price_range TEXT,
|
||||||
|
contact_for_pricing INTEGER NOT NULL DEFAULT 0,
|
||||||
|
quantity TEXT,
|
||||||
|
availability_status TEXT NOT NULL DEFAULT 'Available',
|
||||||
|
custom_order_available INTEGER NOT NULL DEFAULT 0,
|
||||||
|
color_size_notes TEXT,
|
||||||
|
material_notes TEXT,
|
||||||
|
care_instructions TEXT,
|
||||||
|
featured INTEGER NOT NULL DEFAULT 0,
|
||||||
|
newly_added INTEGER NOT NULL DEFAULT 0,
|
||||||
|
on_sale INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sale_text TEXT,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_published INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
draft_json TEXT,
|
||||||
|
date_added TEXT NOT NULL,
|
||||||
|
published_at TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY(main_image_id) REFERENCES media(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_images (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
media_id INTEGER NOT NULL,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_main INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_tags (
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
tag_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(item_id, tag_id),
|
||||||
|
FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS services (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
image_id INTEGER,
|
||||||
|
short_description TEXT,
|
||||||
|
full_description TEXT,
|
||||||
|
pricing_note TEXT,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
featured INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_published INTEGER NOT NULL DEFAULT 1,
|
||||||
|
draft_json TEXT,
|
||||||
|
published_at TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(image_id) REFERENCES media(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS equipment (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT,
|
||||||
|
brand TEXT,
|
||||||
|
model TEXT,
|
||||||
|
description TEXT,
|
||||||
|
image_id INTEGER,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_published INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(image_id) REFERENCES media(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
phone TEXT,
|
||||||
|
preferred_contact TEXT,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
related_item TEXT,
|
||||||
|
related_service TEXT,
|
||||||
|
consent INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source_page TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unread',
|
||||||
|
spam_status TEXT NOT NULL DEFAULT 'clean',
|
||||||
|
ip_hash TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
email_notification_status TEXT NOT NULL DEFAULT 'not_configured',
|
||||||
|
email_error TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS navigation (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'internal',
|
||||||
|
opens_new_tab INTEGER NOT NULL DEFAULT 0,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_published INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS revisions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
record_type TEXT NOT NULL,
|
||||||
|
record_id INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
snapshot_json TEXT NOT NULL,
|
||||||
|
created_by INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(created_by) REFERENCES administrators(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
admin_id INTEGER,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(admin_id) REFERENCES administrators(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
count INTEGER NOT NULL,
|
||||||
|
reset_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_public ON items(is_published, is_archived, name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_flags ON items(featured, newly_added, on_sale);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_services_public ON services(is_published, display_order);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_page_sections_order ON page_sections(page_id, display_order);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_revisions_record ON revisions(record_type, record_id, created_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSetting(key, value) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||||
|
`).run(key, String(value ?? ""), now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSetting(key, fallback = "") {
|
||||||
|
const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
|
||||||
|
return row ? row.value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allSettings() {
|
||||||
|
const rows = db.prepare("SELECT key, value FROM settings").all();
|
||||||
|
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCategory(name) {
|
||||||
|
const slug = makeSlug(name, "category");
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO categories (name, slug, display_order)
|
||||||
|
VALUES (?, ?, (SELECT COALESCE(MAX(display_order), 0) + 10 FROM categories))
|
||||||
|
ON CONFLICT(name) DO NOTHING
|
||||||
|
`).run(name, slug);
|
||||||
|
return db.prepare("SELECT id FROM categories WHERE name = ?").get(name).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed() {
|
||||||
|
const seeded = getSetting("system.seeded", "");
|
||||||
|
if (seeded) return;
|
||||||
|
|
||||||
|
const ts = now();
|
||||||
|
const settings = {
|
||||||
|
"system.seeded": ts,
|
||||||
|
"business.name": "Debbie Windler Seamstress",
|
||||||
|
"business.tagline": "Alterations, custom sewing, embroidery, and handmade items",
|
||||||
|
"business.email": "replace-with-owner-email@example.com",
|
||||||
|
"business.phone": "",
|
||||||
|
"business.facebook": "",
|
||||||
|
"business.service_area": "Service area to be supplied",
|
||||||
|
"business.hours": "By appointment",
|
||||||
|
"business.preferred_contact": "Email or website contact form",
|
||||||
|
"business.footer_text": "Warm, careful sewing work for everyday clothing and special occasions.",
|
||||||
|
"site.title": "Debbie Windler Seamstress",
|
||||||
|
"site.description": "Alterations, custom sewing, embroidery, and handmade items.",
|
||||||
|
"theme.bg": "#fffaf5",
|
||||||
|
"theme.bg_alt": "#f4e8df",
|
||||||
|
"theme.text": "#342b28",
|
||||||
|
"theme.heading": "#2d2522",
|
||||||
|
"theme.link": "#7d3f45",
|
||||||
|
"theme.button": "#7d3f45",
|
||||||
|
"theme.button_text": "#ffffff",
|
||||||
|
"theme.accent": "#7a8f73",
|
||||||
|
"theme.radius": "8",
|
||||||
|
"theme.spacing": "comfortable",
|
||||||
|
"theme.width": "1120",
|
||||||
|
"theme.heading_font": "Georgia, 'Times New Roman', serif",
|
||||||
|
"theme.body_font": "Arial, Helvetica, sans-serif",
|
||||||
|
"theme.footer_style": "light",
|
||||||
|
"home.hero.title": "Debbie Windler Seamstress",
|
||||||
|
"home.hero.subtitle": "Alterations, custom sewing, embroidery, and handmade pieces, with sample wording ready for Debbie to replace.",
|
||||||
|
"home.hero.contact": "Contact Debbie to talk through your project.",
|
||||||
|
"home.hero.image": "/uploads/sample-hero.png",
|
||||||
|
"home.hero.position": "center",
|
||||||
|
"home.hero.size": "cover",
|
||||||
|
"home.hero.overlay": "light",
|
||||||
|
"home.hero.overlay_opacity": "0.28",
|
||||||
|
"home.hero.text_color": "#2d2522",
|
||||||
|
"home.hero.button1_label": "Contact Debbie",
|
||||||
|
"home.hero.button1_url": "/about-contact#contact-form",
|
||||||
|
"home.hero.button2_label": "View Items",
|
||||||
|
"home.hero.button2_url": "/items",
|
||||||
|
"home.highlight.title": "Featured and Newly Added",
|
||||||
|
"home.highlight.intro": "Sample cards show how items can be featured, marked new, or offered as custom orders.",
|
||||||
|
"home.highlight.background_color": "#f4e8df",
|
||||||
|
"maintenance.enabled": "false",
|
||||||
|
"maintenance.message": "The website is getting a careful refresh. Please check back soon.",
|
||||||
|
"maintenance.return_note": "",
|
||||||
|
"maintenance.artwork": ""
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(settings)) setSetting(key, value);
|
||||||
|
|
||||||
|
const pageInsert = db.prepare(`
|
||||||
|
INSERT INTO pages (slug, title, subtitle, intro, meta_title, meta_description, status, published_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?)
|
||||||
|
`);
|
||||||
|
const pages = [
|
||||||
|
["home", "Home", "Welcome to Debbie Windler Seamstress", "Sample home page copy can be replaced from the owner area."],
|
||||||
|
["items", "Items Available and Custom Orders", "Browse sample items alphabetically.", "Items can be available, made to order, on sale, sold as examples, seasonal, or hidden."],
|
||||||
|
["services", "Services", "Alterations, repairs, custom sewing, and embroidery.", "Service examples are seeded so Debbie can edit or remove them."],
|
||||||
|
["about-contact", "About and Contact", "A personal introduction and the full contact form.", "This page keeps Debbie's contact details editable and does not show a private home address by default."]
|
||||||
|
];
|
||||||
|
for (const page of pages) pageInsert.run(page[0], page[1], page[2], page[3], page[1], page[3], ts, ts);
|
||||||
|
|
||||||
|
const pageBySlug = db.prepare("SELECT id FROM pages WHERE slug = ?");
|
||||||
|
const sectionInsert = db.prepare(`
|
||||||
|
INSERT INTO page_sections
|
||||||
|
(page_id, section_key, title, body, layout, display_order, is_published, data_json, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||||
|
`);
|
||||||
|
sectionInsert.run(pageBySlug.get("home").id, "intro", "Sewing Services With a Personal Touch", "<p>This sample introduction can describe Debbie's experience, favorite work, and how customers should reach out.</p>", "image-right", 10, "{}", ts);
|
||||||
|
sectionInsert.run(pageBySlug.get("home").id, "contact-callout", "Have a project in mind?", "<p>Use the contact form to ask about alterations, embroidery, handmade items, or special orders.</p>", "contact-callout", 30, "{}", ts);
|
||||||
|
sectionInsert.run(pageBySlug.get("services").id, "service-note", "Not sure what to ask for?", "<p>Send a note with the garment or project details. Photos can be discussed later by email if needed.</p>", "contact-callout", 20, "{}", ts);
|
||||||
|
sectionInsert.run(pageBySlug.get("about-contact").id, "about", "About Debbie", "<p>Sample biography text: Debbie's real sewing history, experience, and photos can be added here from the owner area.</p>", "text", 10, "{}", ts);
|
||||||
|
|
||||||
|
const categories = ["Alterations", "Handmade Items", "Embroidery", "Custom Orders", "Seasonal"];
|
||||||
|
const categoryIds = Object.fromEntries(categories.map((name) => [name, ensureCategory(name)]));
|
||||||
|
const itemInsert = db.prepare(`
|
||||||
|
INSERT INTO items
|
||||||
|
(name, slug, short_description, full_description, category_id, price, starting_price, price_range, contact_for_pricing,
|
||||||
|
availability_status, custom_order_available, material_notes, care_instructions, featured, newly_added, on_sale,
|
||||||
|
sale_text, display_order, is_published, date_added, published_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const sampleItems = [
|
||||||
|
["Baby Blanket Example", "Soft sample listing for a custom baby blanket.", "<p>Replace this with true details, sizes, fabrics, and ordering notes.</p>", "Handmade Items", "", "Contact for starting price", "", 1, "Made to Order", 1, "Fabric details to be supplied.", "Care instructions to be supplied.", 1, 1, 0, "", 10],
|
||||||
|
["Embroidered Tote Bag", "Sample tote bag entry for embroidery or custom wording.", "<p>This is sample content only and can be changed or hidden.</p>", "Embroidery", "", "", "", 1, "Custom Order", 1, "Canvas or fabric notes can go here.", "Spot clean or care note to be supplied.", 1, 1, 0, "", 20],
|
||||||
|
["Formalwear Alteration Example", "A sold/example listing showing previous-style work without checkout.", "<p>Use sold examples to show the type of work Debbie can discuss with customers.</p>", "Alterations", "", "", "", 1, "Sold", 0, "", "", 0, 0, 0, "", 30],
|
||||||
|
["Seasonal Pillow Cover", "Sample seasonal handmade item.", "<p>Mark items seasonal, available, hidden, sold, or made to order.</p>", "Seasonal", "$00 sample", "", "", 0, "Available", 1, "Material notes to be supplied.", "Care instructions to be supplied.", 1, 0, 1, "Sample sale badge", 40]
|
||||||
|
];
|
||||||
|
for (const item of sampleItems) {
|
||||||
|
itemInsert.run(item[0], makeSlug(item[0], "item"), item[1], item[2], categoryIds[item[3]], item[4], item[5], item[6], item[7], item[8], item[9], item[10], item[11], item[12], item[13], item[14], item[15], item[16], ts, ts, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceInsert = db.prepare(`
|
||||||
|
INSERT INTO services
|
||||||
|
(name, slug, short_description, full_description, pricing_note, display_order, featured, is_published, published_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||||
|
`);
|
||||||
|
[
|
||||||
|
["Clothing Alterations", "Hems, fit adjustments, and everyday garment alterations.", "<p>Sample service details. Debbie can replace this with her exact services and policies.</p>", "Pricing depends on the garment and work needed.", 10, 1],
|
||||||
|
["Zipper Repair and Replacement", "Repair or replace zippers on clothing and fabric items.", "<p>Describe accepted items and turnaround once details are known.</p>", "Contact for pricing.", 20, 1],
|
||||||
|
["Wedding Dress Alterations", "Fittings and alterations for wedding dresses and formalwear.", "<p>Sample text only. Add appointment expectations and timing when ready.</p>", "Contact early for availability.", 30, 1],
|
||||||
|
["Embroidery", "Custom embroidery projects and decorative additions.", "<p>Describe machine embroidery options, setup needs, and project limits.</p>", "Contact for pricing.", 40, 1],
|
||||||
|
["Baby Blankets", "Handmade or custom baby blanket projects.", "<p>Describe fabric choices, size options, and custom order timing.</p>", "Starting price to be supplied.", 50, 0],
|
||||||
|
["Patches Sewn Onto Clothing", "Patch placement and sewing for jackets, uniforms, and garments.", "<p>Describe accepted materials and any preparation instructions.</p>", "Contact for pricing.", 60, 0]
|
||||||
|
].forEach((service) => serviceInsert.run(service[0], makeSlug(service[0], "service"), service[1], service[2], service[3], service[4], service[5], ts, ts));
|
||||||
|
|
||||||
|
const equipmentInsert = db.prepare(`
|
||||||
|
INSERT INTO equipment (name, type, brand, model, description, display_order, is_published, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1, ?)
|
||||||
|
`);
|
||||||
|
equipmentInsert.run("Industrial Sewing Machine", "Sewing machine", "", "", "Sample equipment entry. Add the real brand, model, and photo when available.", 10, ts);
|
||||||
|
equipmentInsert.run("Embroidery Machine", "Embroidery machine", "", "", "Sample equipment entry for embroidery work.", 20, ts);
|
||||||
|
equipmentInsert.run("Thread Rack", "Supplies", "", "", "Sample equipment entry for thread colors and materials.", 30, ts);
|
||||||
|
|
||||||
|
const navInsert = db.prepare("INSERT INTO navigation (label, url, kind, display_order, is_published) VALUES (?, ?, 'internal', ?, 1)");
|
||||||
|
[
|
||||||
|
["Home", "/", 10],
|
||||||
|
["Items", "/items", 20],
|
||||||
|
["Services", "/services", 30],
|
||||||
|
["About and Contact", "/about-contact", 40]
|
||||||
|
].forEach((nav) => navInsert.run(...nav));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRevision(type, id, title, snapshot, adminId) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO revisions (record_type, record_id, title, snapshot_json, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(type, id, title || "", JSON.stringify(snapshot), adminId || null, now());
|
||||||
|
|
||||||
|
const old = db.prepare(`
|
||||||
|
SELECT id FROM revisions
|
||||||
|
WHERE record_type = ? AND record_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT -1 OFFSET 10
|
||||||
|
`).all(type, id);
|
||||||
|
if (old.length) {
|
||||||
|
db.prepare(`DELETE FROM revisions WHERE id IN (${old.map(() => "?").join(",")})`).run(...old.map((row) => row.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function audit(adminId, action, details, ipAddress) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO audit_logs (admin_id, action, details, ip_address, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(adminId || null, action, details || "", ipAddress || "", now());
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate();
|
||||||
|
seed();
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
console.log(`Database ready at ${config.databasePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
db,
|
||||||
|
migrate,
|
||||||
|
seed,
|
||||||
|
setSetting,
|
||||||
|
getSetting,
|
||||||
|
allSettings,
|
||||||
|
ensureCategory,
|
||||||
|
saveRevision,
|
||||||
|
audit
|
||||||
|
};
|
||||||
280
src/render.js
Normal file
280
src/render.js
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
const { escapeHtml, cleanRichText, bytes } = require("./utils");
|
||||||
|
|
||||||
|
function setting(settings, key, fallback = "") {
|
||||||
|
return settings[key] ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicLayout({ title, description, settings, nav = [], body, csrfToken = "", currentPath = "", extraHead = "" }) {
|
||||||
|
const business = setting(settings, "business.name", "Debbie Windler Seamstress");
|
||||||
|
const theme = themeVars(settings);
|
||||||
|
const navHtml = nav.map((link) => `
|
||||||
|
<a class="${currentPath === link.url ? "active" : ""}" href="${escapeHtml(link.url)}" ${link.opens_new_tab ? 'target="_blank" rel="noopener noreferrer"' : ""}>${escapeHtml(link.label)}</a>
|
||||||
|
`).join("");
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${escapeHtml(title || setting(settings, "site.title", business))}</title>
|
||||||
|
<meta name="description" content="${escapeHtml(description || setting(settings, "site.description", ""))}">
|
||||||
|
<meta property="og:title" content="${escapeHtml(title || business)}">
|
||||||
|
<meta property="og:description" content="${escapeHtml(description || setting(settings, "site.description", ""))}">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta name="theme-color" content="${escapeHtml(setting(settings, "theme.button", "#7d3f45"))}">
|
||||||
|
<link rel="stylesheet" href="/css/site.css">
|
||||||
|
<style>:root{${theme}}</style>
|
||||||
|
${extraHead}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">Skip to content</a>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href="/" aria-label="${escapeHtml(business)} home">
|
||||||
|
<span class="brand-mark" aria-hidden="true"></span>
|
||||||
|
<span>${escapeHtml(business)}</span>
|
||||||
|
</a>
|
||||||
|
<button class="menu-toggle" data-menu-toggle aria-expanded="false" aria-controls="site-menu">Menu</button>
|
||||||
|
<nav id="site-menu" class="site-nav" aria-label="Main navigation">
|
||||||
|
${navHtml}
|
||||||
|
<a class="nav-cta" href="/about-contact#contact-form">Contact Debbie</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main id="main">${body}</main>
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(business)}</strong>
|
||||||
|
<p>${escapeHtml(setting(settings, "business.footer_text", ""))}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
${contactLine(settings)}
|
||||||
|
<p>${escapeHtml(setting(settings, "business.hours", "By appointment"))}</p>
|
||||||
|
</div>
|
||||||
|
<nav aria-label="Footer navigation">${navHtml}</nav>
|
||||||
|
<p class="copyright">© ${new Date().getFullYear()} ${escapeHtml(business)}. ${escapeHtml(setting(settings, "business.copyright", "All rights reserved."))}</p>
|
||||||
|
</footer>
|
||||||
|
<form hidden method="post"><input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}"></form>
|
||||||
|
<script src="/js/site.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminLayout({ title, body, admin, csrfToken, active = "" }) {
|
||||||
|
const menu = [
|
||||||
|
["dashboard", "Dashboard", "/admin/dashboard"],
|
||||||
|
["home", "Home Page", "/admin/home"],
|
||||||
|
["pages", "Pages", "/admin/pages"],
|
||||||
|
["items", "Items", "/admin/items"],
|
||||||
|
["services", "Services", "/admin/services"],
|
||||||
|
["about", "About and Equipment", "/admin/about-equipment"],
|
||||||
|
["messages", "Messages", "/admin/messages"],
|
||||||
|
["media", "Media Library", "/admin/media"],
|
||||||
|
["appearance", "Appearance", "/admin/appearance"],
|
||||||
|
["navigation", "Navigation", "/admin/navigation"],
|
||||||
|
["business", "Business Settings", "/admin/business"],
|
||||||
|
["seo", "Search and Sharing", "/admin/seo"],
|
||||||
|
["security", "Security", "/admin/security"],
|
||||||
|
["backups", "Backups", "/admin/backups"],
|
||||||
|
["maintenance", "Maintenance", "/admin/maintenance"]
|
||||||
|
];
|
||||||
|
const menuHtml = menu.map(([key, label, url]) => `<a class="${active === key ? "active" : ""}" href="${url}">${label}</a>`).join("");
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${escapeHtml(title)} - Owner Area</title>
|
||||||
|
<link rel="stylesheet" href="/css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#admin-main">Skip to content</a>
|
||||||
|
<aside class="admin-sidebar">
|
||||||
|
<a class="admin-brand" href="/admin/dashboard">Debbie Windler<br><span>Owner Area</span></a>
|
||||||
|
<nav aria-label="Owner area">${menuHtml}</nav>
|
||||||
|
<a href="/" target="_blank" rel="noopener noreferrer">Preview Website</a>
|
||||||
|
<form method="post" action="/admin/logout">
|
||||||
|
<input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}">
|
||||||
|
<button type="submit" class="link-button">Log Out</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
<main id="admin-main" class="admin-main">
|
||||||
|
<header class="admin-top">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Owner administration</p>
|
||||||
|
<h1>${escapeHtml(title)}</h1>
|
||||||
|
</div>
|
||||||
|
<p class="admin-user">${escapeHtml(admin?.name || "")}</p>
|
||||||
|
</header>
|
||||||
|
${body}
|
||||||
|
</main>
|
||||||
|
<script src="/js/admin.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authLayout(title, body) {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${escapeHtml(title)} - Debbie Windler Seamstress</title>
|
||||||
|
<link rel="stylesheet" href="/css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body class="auth-page">
|
||||||
|
<main class="auth-card">
|
||||||
|
<h1>${escapeHtml(title)}</h1>
|
||||||
|
${body}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeVars(settings) {
|
||||||
|
const keys = {
|
||||||
|
"--bg": ["theme.bg", "#fffaf5"],
|
||||||
|
"--bg-alt": ["theme.bg_alt", "#f4e8df"],
|
||||||
|
"--text": ["theme.text", "#342b28"],
|
||||||
|
"--heading": ["theme.heading", "#2d2522"],
|
||||||
|
"--link": ["theme.link", "#7d3f45"],
|
||||||
|
"--button": ["theme.button", "#7d3f45"],
|
||||||
|
"--button-text": ["theme.button_text", "#ffffff"],
|
||||||
|
"--accent": ["theme.accent", "#7a8f73"],
|
||||||
|
"--radius": ["theme.radius", "8"],
|
||||||
|
"--site-width": ["theme.width", "1120"]
|
||||||
|
};
|
||||||
|
const css = Object.entries(keys).map(([name, [key, fallback]]) => `${name}:${escapeHtml(setting(settings, key, fallback))}${name === "--radius" || name === "--site-width" ? "px" : ""};`);
|
||||||
|
css.push(`--heading-font:${escapeHtml(setting(settings, "theme.heading_font", "Georgia, 'Times New Roman', serif"))};`);
|
||||||
|
css.push(`--body-font:${escapeHtml(setting(settings, "theme.body_font", "Arial, Helvetica, sans-serif"))};`);
|
||||||
|
return css.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactLine(settings) {
|
||||||
|
const email = setting(settings, "business.email", "");
|
||||||
|
const phone = setting(settings, "business.phone", "");
|
||||||
|
const facebook = setting(settings, "business.facebook", "");
|
||||||
|
return `
|
||||||
|
${email ? `<p><a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a></p>` : ""}
|
||||||
|
${phone ? `<p><a href="tel:${escapeHtml(phone)}">${escapeHtml(phone)}</a></p>` : ""}
|
||||||
|
${facebook ? `<p><a href="${escapeHtml(facebook)}" target="_blank" rel="noopener noreferrer">Facebook</a></p>` : ""}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(label, href, className = "button") {
|
||||||
|
if (!label || !href) return "";
|
||||||
|
return `<a class="${className}" href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemCard(item) {
|
||||||
|
const badgeBits = [item.newly_added ? "New" : "", item.on_sale ? "Sale" : "", item.availability_status].filter(Boolean);
|
||||||
|
return `<article class="item-card">
|
||||||
|
${item.image_url ? `<img src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.image_alt || item.name)}" loading="lazy">` : `<div class="image-placeholder" aria-hidden="true"></div>`}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="badges">${badgeBits.map((badge) => `<span>${escapeHtml(badge)}</span>`).join("")}</div>
|
||||||
|
<h3><a href="/items/${escapeHtml(item.slug)}">${escapeHtml(item.name)}</a></h3>
|
||||||
|
<p>${escapeHtml(item.short_description || "")}</p>
|
||||||
|
<p class="price">${priceText(item)}</p>
|
||||||
|
<div class="card-actions">
|
||||||
|
${button("View Details", `/items/${item.slug}`, "text-button")}
|
||||||
|
${button("Ask About This Item", `/about-contact?item=${encodeURIComponent(item.name)}#contact-form`, "button small")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceCard(service) {
|
||||||
|
return `<article class="service-card">
|
||||||
|
${service.image_url ? `<img src="${escapeHtml(service.image_url)}" alt="${escapeHtml(service.image_alt || service.name)}" loading="lazy">` : `<div class="image-placeholder thread" aria-hidden="true"></div>`}
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>${escapeHtml(service.name)}</h3>
|
||||||
|
<p>${escapeHtml(service.short_description || "")}</p>
|
||||||
|
${service.pricing_note ? `<p class="pricing-note">${escapeHtml(service.pricing_note)}</p>` : ""}
|
||||||
|
${button("Ask About This Service", `/about-contact?service=${encodeURIComponent(service.name)}#contact-form`, "button small")}
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceText(item) {
|
||||||
|
if (item.contact_for_pricing) return "Contact for pricing";
|
||||||
|
if (item.price_range) return escapeHtml(item.price_range);
|
||||||
|
if (item.starting_price) return `Starting at ${escapeHtml(item.starting_price)}`;
|
||||||
|
if (item.price) return escapeHtml(item.price);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactForm({ csrfToken, item = "", service = "", success = false, settings = {} }) {
|
||||||
|
return `<section id="contact-form" class="contact-form-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p class="eyebrow">Contact</p>
|
||||||
|
<h2>Contact Debbie</h2>
|
||||||
|
<p>${escapeHtml(setting(settings, "business.preferred_contact", "Email or the website form are preferred."))}</p>
|
||||||
|
${success ? `<p class="notice success" role="status">Thank you. Your message was saved and Debbie will be notified if email is configured.</p>` : ""}
|
||||||
|
</div>
|
||||||
|
<form class="contact-form" method="post" action="/contact" novalidate>
|
||||||
|
<input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}">
|
||||||
|
<input type="hidden" name="source_page" value="/about-contact">
|
||||||
|
<label class="hp">Leave this field empty <input type="text" name="website"></label>
|
||||||
|
<label>Name <input required maxlength="120" name="name" autocomplete="name"></label>
|
||||||
|
<label>Email <input required maxlength="180" type="email" name="email" autocomplete="email"></label>
|
||||||
|
<label>Phone, optional <input maxlength="80" name="phone" autocomplete="tel"></label>
|
||||||
|
<label>Preferred contact method
|
||||||
|
<select name="preferred_contact">
|
||||||
|
<option>Email</option>
|
||||||
|
<option>Phone</option>
|
||||||
|
<option>Text message</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Subject <input required maxlength="160" name="subject" value="${escapeHtml(item ? `Question about ${item}` : service ? `Question about ${service}` : "")}"></label>
|
||||||
|
<label>Item or service being discussed <input maxlength="160" name="related" value="${escapeHtml(item || service)}"></label>
|
||||||
|
<label>Message <textarea required maxlength="3000" name="message" rows="7"></textarea></label>
|
||||||
|
<label class="checkbox"><input type="checkbox" required name="consent" value="1"> I understand this information will be used to answer my inquiry.</label>
|
||||||
|
<button type="submit">Send Message</button>
|
||||||
|
</form>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionHtml(section) {
|
||||||
|
if (!section.is_published) return "";
|
||||||
|
const style = [
|
||||||
|
section.background_color ? `background-color:${escapeHtml(section.background_color)}` : "",
|
||||||
|
section.background_url ? `background-image:url('${escapeHtml(section.background_url)}')` : ""
|
||||||
|
].filter(Boolean).join(";");
|
||||||
|
const image = section.image_url ? `<img src="${escapeHtml(section.image_url)}" alt="${escapeHtml(section.image_alt || section.title || "")}" loading="lazy">` : "";
|
||||||
|
const content = `<div class="section-copy">
|
||||||
|
${section.title ? `<h2>${escapeHtml(section.title)}</h2>` : ""}
|
||||||
|
${cleanRichText(section.body || "")}
|
||||||
|
${button(section.button_label, section.button_url)}
|
||||||
|
</div>`;
|
||||||
|
if (section.layout === "image-left") return `<section class="content-section split" style="${style}">${image}${content}</section>`;
|
||||||
|
if (section.layout === "image-right") return `<section class="content-section split reverse" style="${style}">${content}${image}</section>`;
|
||||||
|
if (section.layout === "full-image") return `<section class="content-section full-image" style="${style}">${image}${content}</section>`;
|
||||||
|
if (section.layout === "contact-callout") return `<section class="content-section contact-callout" style="${style}">${content}</section>`;
|
||||||
|
return `<section class="content-section" style="${style}">${content}</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaOption(media, selected) {
|
||||||
|
return `<option value="${media.id}" ${Number(selected) === Number(media.id) ? "selected" : ""}>${escapeHtml(media.title || media.original_name)}</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaGrid(media) {
|
||||||
|
return `<div class="media-grid">${media.map((m) => `
|
||||||
|
<article class="media-card">
|
||||||
|
<img src="${escapeHtml(m.thumb_url || m.url)}" alt="${escapeHtml(m.alt_text || m.title || "")}">
|
||||||
|
<strong>${escapeHtml(m.title || m.original_name)}</strong>
|
||||||
|
<small>${escapeHtml(m.mime_type)} · ${bytes(m.size_bytes || 0)} ${m.width ? `· ${m.width}x${m.height}` : ""}</small>
|
||||||
|
</article>
|
||||||
|
`).join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
publicLayout,
|
||||||
|
adminLayout,
|
||||||
|
authLayout,
|
||||||
|
setting,
|
||||||
|
button,
|
||||||
|
itemCard,
|
||||||
|
serviceCard,
|
||||||
|
priceText,
|
||||||
|
contactForm,
|
||||||
|
sectionHtml,
|
||||||
|
mediaOption,
|
||||||
|
mediaGrid
|
||||||
|
};
|
||||||
1093
src/server.js
Normal file
1093
src/server.js
Normal file
File diff suppressed because it is too large
Load Diff
84
src/utils.js
Normal file
84
src/utils.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
|
const sanitizeHtml = require("sanitize-html");
|
||||||
|
const slugify = require("slugify");
|
||||||
|
|
||||||
|
function escapeHtml(value = "") {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanRichText(value = "") {
|
||||||
|
return sanitizeHtml(String(value), {
|
||||||
|
allowedTags: ["p", "br", "strong", "b", "em", "i", "h2", "h3", "h4", "ul", "ol", "li", "a"],
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ["href", "target", "rel"]
|
||||||
|
},
|
||||||
|
allowedSchemes: ["http", "https", "mailto", "tel"],
|
||||||
|
transformTags: {
|
||||||
|
a: (tagName, attribs) => ({
|
||||||
|
tagName,
|
||||||
|
attribs: {
|
||||||
|
href: attribs.href || "#",
|
||||||
|
target: attribs.target === "_blank" ? "_blank" : undefined,
|
||||||
|
rel: attribs.target === "_blank" ? "noopener noreferrer" : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSlug(value, fallback = "entry") {
|
||||||
|
const slug = slugify(String(value || ""), { lower: true, strict: true, trim: true });
|
||||||
|
return slug || `${fallback}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function token(size = 24) {
|
||||||
|
return crypto.randomBytes(size).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytes(n = 0) {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkbox(value) {
|
||||||
|
return value ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFromText(value = "") {
|
||||||
|
return String(value)
|
||||||
|
.split(/\r?\n|,/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(value, fallback = {}) {
|
||||||
|
if (!value) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
escapeHtml,
|
||||||
|
cleanRichText,
|
||||||
|
makeSlug,
|
||||||
|
token,
|
||||||
|
now,
|
||||||
|
bytes,
|
||||||
|
checkbox,
|
||||||
|
listFromText,
|
||||||
|
safeJson
|
||||||
|
};
|
||||||
1
storage/server.pid
Normal file
1
storage/server.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
32184
|
||||||
0
storage/server.stderr.log
Normal file
0
storage/server.stderr.log
Normal file
0
storage/server.stdout.log
Normal file
0
storage/server.stdout.log
Normal file
26
test/smoke.test.js
Normal file
26
test/smoke.test.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const request = require("supertest");
|
||||||
|
const { app } = require("../src/server");
|
||||||
|
|
||||||
|
test("public pages load", async () => {
|
||||||
|
for (const path of ["/", "/items", "/services", "/about-contact", "/healthz"]) {
|
||||||
|
const response = await request(app).get(path);
|
||||||
|
assert.equal(response.status < 500, true, `${path} returned ${response.status}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin redirects to setup or login", async () => {
|
||||||
|
const response = await request(app).get("/admin");
|
||||||
|
assert.equal([302, 303].includes(response.status), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contact form requires complete fields", async () => {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
const page = await agent.get("/about-contact");
|
||||||
|
const token = /name="_csrf" value="([^"]+)"/.exec(page.text)?.[1];
|
||||||
|
const response = await agent
|
||||||
|
.post("/contact")
|
||||||
|
.send(`_csrf=${encodeURIComponent(token || "")}&name=&email=bad&subject=&message=`);
|
||||||
|
assert.equal(response.status, 400);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user