A web app for Croatian children’s bedtime stories, built with Next.js, SQLite, and Tailwind CSS.
- 📚 Collection of Croatian bedtime stories for kids (ages 5–10)
- 🌙 Dark theme with navy, slate, and amber
- ⭐ 5-star rating system (ratings stored per user, averages computed)
- 💬 Comments per story with simple math CAPTCHA and IP rate limiting
- 📖 List and gallery view modes with search, filters (author, rating, reading time, read/unread), and sort
- ✅ Mark stories as read (stored in localStorage) with grayed-out styling
- 📄 “Load more” pagination on the story list
- 🎯 Preporučeno za večeras – recommended unread stories by reading time
- 🔗 Ostale priče – related stories (same author, then others) on each story page
- 🔐 Admin area: approve/delete stories, HTTP-only cookie auth, login rate limiting
- ♿ Keyboard and screen-reader friendly (ARIA, focus styles, scroll-to-comments)
- 📱 Responsive layout
- 🎨 Optional AI-generated illustrations (OpenAI DALL-E 3)
- Framework: Next.js 16 (App Router)
- Database: SQLite with
better-sqlite3 - Styling: Tailwind CSS 4
- Language: TypeScript
- Validation: Zod (API request bodies)
- Image generation: OpenAI DALL-E 3 (optional, via scripts)
- Node.js 20+ (or Docker and Docker Compose)
Copy .env.example to .env:
cp .env.example .envConfigure:
| Variable | Purpose |
|---|---|
ADMIN_PASSWORD |
Password for /admin (required in production) |
OPENAI_API_KEY |
For image-generation scripts (optional) |
NEXT_PUBLIC_SITE_URL |
Base URL for SEO/canonical links (e.g. https://pricezalakunoc.hr) |
DEBUG / NEXT_PUBLIC_DEBUG |
Set to true to enable debug logging |
LOG_JSON |
Set to 1 or true for JSON-structured logs (e.g. in production) |
The .env file is gitignored. If any secret was ever committed, rotate it and update .env and deployment secrets.
npm install
npm run devOpen http://localhost:3000.
docker compose pull
# OR build locally
docker compose build
docker compose up -dApp: http://localhost:8889.
docker run -d \
--name fairytale-app \
-p 8889:3000 \
-v $(pwd)/data:/app/data \
-e ADMIN_PASSWORD=your-secure-password \
ghcr.io/scallywer/fairytale:latestThe SQLite database lives in ./data, mounted as a volume. Back up this directory regularly.
| Endpoint | Method | Description |
|---|---|---|
/api/stories |
GET | Approved stories (optional ?limit=&offset= for pagination) |
/api/stories |
POST | Submit a new story (Zod-validated) |
/api/comments?storyId= |
GET | Comments for a story |
/api/comments |
POST | Add comment (Zod, math check, IP rate limit) |
/api/ratings |
POST | Submit rating 1–5 (Zod) |
/api/admin |
GET/POST | Admin actions (cookie auth, rate-limited login) |
/api/health |
GET | Health check (returns 200 and DB status) |
/api/analytics |
POST | Optional event logging (no persistence, returns 204) |
SQLite with three main tables:
- stories – id, title, author, body, imageUrl, isApproved, createdAt, updatedAt. Reading time and average rating are computed at read time from
ratings. - ratings – id, storyId, userId, rating (1–5), createdAt. One rating per user per story.
- comments – id, storyId, authorName, content, isApproved, createdAt.
Domain logic lives in lib/storiesService.ts, lib/ratingsService.ts, and lib/commentsService.ts.
| Command | Description |
|---|---|
npm run dev |
Start dev server |
npm run build |
Production build |
npm run start |
Run production server |
npm run lint |
Run ESLint |
npm test |
Run tests (Vitest) |
npm run generate-images |
Generate story images (OpenAI; needs API key) |
npm run export-prompts |
Export DALL-E prompts to file |
npm run update-image-links |
Sync image URLs in DB from local files |
npm run review-prompts |
Review prompts |
npm run regenerate-specific |
Regenerate images for selected stories |
npm run expand-stories |
Expand stories script |
npm run export-stories-list |
Export story list to file |
After adding stories or images, run npm run update-image-links then npm test.
npm testRuns Vitest (e.g. image mapping, stories API, UI components). For a single file:
npx vitest tests/image-mapping.test.ts
npx vitest tests/api-stories.test.ts- CI (
.github/workflows/ci.yml) – on push/PR tomain/master:npm ci,npm run lint,npx tsc --noEmit,npm test. - Docker (
.github/workflows/docker-build.yml) – build and push image to GitHub Container Registry; imageghcr.io/scallywer/fairytale:latest, tags for branch, SHA, and version.
The SQLite DB lives at ./data/stories.db and is mounted into the
container. WAL mode is enabled, so plain file copies can corrupt the
backup — always use the .backup command. A helper is included:
docker exec fairytale-app /app/scripts/backup-db.sh
# → writes /app/data/backups/stories-<UTC-timestamp>.db inside the volumeSuggested host-side cron (daily at 03:00, 14-day retention):
0 3 * * * docker exec fairytale-app /app/scripts/backup-db.shdocker compose stop
cp ./data/backups/stories-<timestamp>.db ./data/stories.db
docker compose start# Set new value in your .env / secret store, then:
docker compose up -dSessions issued under the old password remain valid until they expire
naturally (24h) because session signing now uses ADMIN_SESSION_SECRET,
which is independent of the password. To revoke immediately, also
rotate ADMIN_SESSION_SECRET.
docker compose pull
docker compose up -dThe ./data host bind mount preserves the SQLite DB across image
refreshes. If you ever see "first-run" behavior after a pull, verify
the volume mount in docker-compose.yml is uncommented.
Before publishing a Docker image, scrub private moderation state from the seed DB:
./scripts/scrub-seed-db.shThis drops unapproved stories/comments, all ratings, and all analytics events. The result is what new operators receive on first boot.
See LICENSE. Source code is proprietary; user-submitted content is licensed to the operator under the terms stated there.