Personal art collection site — simplified Pixiv-like experience. Built with Astro 7 hybrid mode on Cloudflare Pages. Markdown content, D1-backed likes, Pagefind search, S3 image pipeline.
├── public/ # Static assets (favicon, fonts)
├── src/
│ ├── assets/ # Image manifest, logo
│ ├── components/ # Astro components (reusable UI)
│ │ ├── ArtworkCard.astro
│ │ ├── ArtworkGallery.astro
│ │ ├── Header.astro / Footer.astro / SEO.astro
│ │ ├── LikeButton.astro / LikeButton.client.js
│ │ ├── PrevNextNav.astro / TagList.astro
│ │ ├── SearchUI.astro / SectionDivider.astro
│ ├── content/
│ │ └── artworks/ # .mdx artwork files (content collection)
│ ├── layouts/
│ │ └── Layout.astro # HTML shell (head, header, footer)
│ ├── lib/
│ │ ├── artworks.ts # getCollection, paginate, allTags, adjacent
│ │ ├── image-url.ts # getImageUrl, getImageManifest
│ │ └── ip.ts # getClientIP, hashIp (HMAC-SHA-256)
│ ├── pages/
│ │ ├── index.astro # Home page (intro + latest 12)
│ │ ├── gallery/[...page].astro
│ │ ├── tag/[tag].astro # Tag filter + tag/index.astro listing
│ │ ├── artwork/[slug].astro
│ │ ├── search.astro
│ │ ├── rss.xml.ts # RSS 2.0 feed
│ │ └── api/like.ts # POST /api/like
│ ├── styles/
│ │ └── global.css # Tailwind v4 @theme tokens, prose styles
│ └── content.config.ts # Zod schema for artworks collection
├── d1/migrations/ # D1 SQL migrations
├── scripts/
│ ├── upload.mjs # Image pipeline (sharp + S3)
│ └── setup-hooks.sh # Git hooks setup
├── .githooks/pre-push # Warns about un-uploaded images
├── wrangler.toml # Cloudflare Pages + D1 config
├── astro.config.mjs
└── package.json
npm install
npm run dev # http://localhost:4321Drop a .mdx file in src/content/artworks/:
---
title: "Sunset Over Harbor"
date: 2025-12-20
tags: [landscape, digital, warm]
image:
path: ./sunset.png # local file before upload, S3 key after
alt: "Warm sunset over a quiet harbor"
---
Description text (markdown supported).The dev server picks up new .mdx files automatically. No restart needed.
A single artwork can have one image or multiple images (but not both, and not neither):
images:
- path: artworks/page1.abc12345.webp
alt: "First page"
- path: artworks/page2.def67890.webp
alt: "Second page"Tags are normalized: trim().toLowerCase() applied automatically by the Zod schema.
Images are stored on S3-compatible object storage and served via a separate domain (e.g. img.carbonc.cc). The pipeline processes local images, uploads to S3, and records metadata.
1. Write the .mdx file in src/content/artworks/, referencing local images:
---
title: "Sunset Over Harbor"
date: 2025-12-20
tags: [landscape, digital]
image:
path: ./sunset.png # local file next to the .mdx
alt: "Warm sunset over a quiet harbor"
---2. Upload images to S3 with the pipeline script:
# Single artwork
node scripts/upload.mjs --artwork src/content/artworks/sunset.mdx
# All artworks (batch)
node scripts/upload.mjs --allThe script will:
- Read the local image file from disk
- Convert to lossless WebP (full-size) + 800px lossy thumbnail
- Generate a content-hash filename:
artworks/{12-char-hex}.webp - Upload both to S3
- Rewrite the
pathfield in the.mdxfrontmatter to the S3 key - Record dimensions & size in
src/assets/image-manifest.json
After upload, your frontmatter becomes:
image:
path: artworks/abc123def456.webp # rewritten by the script
alt: "Warm sunset over a quiet harbor"3. Commit and push. Dev server picks up new .mdx automatically; production needs a rebuild + deploy.
By default, every image gets an 800px-wide thumbnail for gallery cards. You can disable this per image:
image:
path: ./icon.png
alt: "A small icon"
thumb: false # skip thumbnail — this image is too smallThumbnails are also auto-skipped for images ≤ 800px wide (upload script detects this).
| Flag | Purpose |
|---|---|
--artwork <path> |
Process a single .mdx file |
--all |
Process all .mdx files in src/content/artworks/ |
--force |
Re-process images that are already S3 keys (use after changing pipeline settings) |
Skip logic: Images whose path already points to artworks/{12-hex}.webp are skipped (already uploaded). Images whose local file doesn't exist on disk are skipped (already processed, file removed). Use --force to override.
Set in .env (or export in shell):
PUBLIC_IMAGE_BASE_URL=https://img.example.com
S3_ENDPOINT=https://s3.example.com
S3_BUCKET=art
S3_REGION=auto
S3_ACCESS_KEY=xxx
S3_SECRET_KEY=xxxLikes use Cloudflare D1 with HMAC-SHA-256 IP hashing.
Local dev server renders like buttons as static (0 likes) — no D1 needed. The button is visible but non-functional.
Requires wrangler + D1:
wrangler d1 create art-archive-db
# Copy database_id into wrangler.toml
wrangler d1 execute art-archive-db --local --file=d1/migrations/0001_create_likes.sql
wrangler devPOST /api/like
Content-Type: application/json
{"slug": "sunset"}
→ 200 { "count": 1, "userLiked": true }
→ 409 { "error": "Already liked" }
→ 503 { "error": "Service unavailable" } (D1 not configured)
Like deduplication: one like per artwork per IP hash. IP read from CF-Connecting-IP header (Cloudflare proxy).
Pagefind indexes the static build output. Search is not functional in dev mode — it shows a friendly message instead.
npm run build # runs: astro build && npx pagefind --site dist/client
npm run preview # serve dist and test search locally/rss.xml — RSS 2.0 with Atom self-link. Enclosures included when image manifest has metadata for the first image.
All colors are CSS custom properties defined in src/styles/global.css under @theme:
| Role | Value |
|---|---|
| Page bg | #f8fafc |
| Card bg | #ffffff |
| Chip bg | #f1f5f9 |
| Border | #e2e8f0 |
| Text primary | #596683 |
| Text secondary | #7188a7 |
| Text muted | #94a3b8 |
| Accent purple | #c4b5fd |
| Accent blue | #bfdafc |
| Accent pink | #f6c0d7 |
| Accent teal | #c3ecf0 |
Font: 寒蝉全圆体 (ChillRoundF) via chinese-font CDN, falling back to system-ui.
| Route | Page |
|---|---|
/ |
Home (latest 12) |
/gallery/, /gallery/2 |
Paginated gallery |
/artwork/[slug]/ |
Artwork detail |
/tag/ |
All tags listing |
/tag/[tag]/ |
Tagged artworks |
/search/ |
Pagefind search |
/rss.xml |
RSS 2.0 feed |
POST /api/like |
Like action |
- Cloudflare account with Pages and D1 access
- Wrangler CLI:
npm install -g wrangler(or usenpx wrangler) - S3-compatible object storage (self-hosted or cloud)
- Node.js >= 22.12.0
# 1. Login to Cloudflare
npx wrangler login
# 2. Create D1 database
npx wrangler d1 create art-archive-db
# → Copy the database_id into wrangler.toml
# 3. Configure wrangler.toml
# Fill in database_id, then add public env vars to [vars] section:
# PUBLIC_SITE_URL, PUBLIC_IMAGE_BASE_URL,
# S3_ENDPOINT, S3_BUCKET, S3_REGION
# 4. Configure .env (build-time vars)
# Copy .env.example and fill in real values.
# PUBLIC_SITE_URL and PUBLIC_IMAGE_BASE_URL are read
# by astro.config.mjs at build time via loadEnv.
# 5. Run local migration (test locally)
npx wrangler d1 execute art-archive-db --file=d1/migrations/0001_create_likes.sql
# 6. Build and deploy as Cloudflare Worker
npm run build
npx wrangler deploy --config dist/server/wrangler.json
# 7. Set secrets for production
npx wrangler secret put SALT
npx wrangler secret put S3_ACCESS_KEY
npx wrangler secret put S3_SECRET_KEY
# 8. Run remote migration (production database)
npx wrangler d1 execute art-archive-db --file=d1/migrations/0001_create_likes.sql --remotebash scripts/setup-hooks.shThe pre-push hook scans staged .mdx files for local image paths and warns if any haven't been uploaded yet. It does not block the push.
- Astro 7 + Tailwind CSS v4 (Vite plugin)
@astrojs/cloudflare(output: static, deployed as Cloudflare Worker)@astrojs/mdx(content collections)- Pagefind (post-build search index)
- Cloudflare D1 (likes)
- sharp + gray-matter +
@aws-sdk/client-s3(image pipeline)