Initial commit: Payload CMS ministerial database

Payload CMS 3 + Next.js app with a Spanish frontend.

- Pastors collection (name, church, location, phones[], Telegram, letter upload)
- PastorLetters upload collection restricted to PDF/Word
- CSV importer (pnpm import:pastors) loading data/pastores_unificado.csv
- Frontend home page: daisyUI pastors data table with search, pagination,
  and create/edit/upload-letter modals (all in Spanish)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Julio Ruiz 2026-08-13 20:55:10 -05:00
commit e1bd3c7949
42 changed files with 12337 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev"],
"port": 3000
}
]
}

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
# SQLite connection (libSQL). A local file DB is created automatically.
# For a hosted DB (e.g. Turso), use a libsql:// URL and set DATABASE_AUTH_TOKEN.
DATABASE_URI=file:./ministerial.db
# Used to sign/verify tokens. Generate a long random value for production.
PAYLOAD_SECRET=YOUR_SECRET_HERE

61
.gitignore vendored Normal file
View File

@ -0,0 +1,61 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
/.idea/*
!/.idea/runConfigurations
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# Claude Code per-user local settings
.claude/settings.local.json
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env
/media
/pastor-letters
# SQLite database files
*.db
*.db-shm
*.db-wal
# Auto-generated AI agent rules (regenerated by Next on dev)
/AGENTS.md
/CLAUDE.md
node_modules/
/playwright-report/
/blob-report/
/playwright/.cache/

1
.npmrc Normal file
View File

@ -0,0 +1 @@
legacy-peer-deps=true

6
.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false
}

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

24
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,24 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
"runtimeArgs": ["--inspect"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "debugWithChrome",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}"
},
"cwd": "${workspaceFolder}"
}
]
}

41
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,41 @@
{
"npm.packageManager": "pnpm",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"editor.formatOnSaveMode": "file",
"js/ts.tsdk.path": "node_modules/typescript/lib",
"js/ts.tsdk.promptToUseWorkspaceVersion": true,
"[javascript][typescript][typescriptreact]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
}

71
Dockerfile Normal file
View File

@ -0,0 +1,71 @@
# To use this Dockerfile, you have to set `output: 'standalone'` in your next.config.mjs file.
# From https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
FROM node:22.17.0-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Remove this line if you do not have this folder
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" node server.js

122
README.md Normal file
View File

@ -0,0 +1,122 @@
# DB Ministerial
A [Payload CMS 3](https://payloadcms.com) content platform with a **Next.js** frontend,
styled with **Tailwind CSS v4** and **daisyUI v5**, backed by **SQLite** (zero-setup).
The CMS admin, REST/GraphQL API, and the public React frontend all live in a single
Next.js app.
## Requirements
- Node.js **20.9+** (22 recommended)
- **pnpm** 9, 10, or 11 (`npm install -g pnpm`)
## Getting started
```bash
pnpm install # install dependencies
cp .env.example .env # then set a real PAYLOAD_SECRET
pnpm dev # start the dev server at http://localhost:3000
```
On first boot, SQLite creates `ministerial.db` automatically and pushes the schema.
Open <http://localhost:3000/admin> and create your first admin user.
### Optional: seed sample content
```bash
pnpm seed
```
Creates an admin user (`admin@example.com` / `changeme123` — change these via
`SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD`), three tags, and three published posts so
the home page has something to render. Safe to re-run; it skips if any user exists.
## Import pastors from the CSV
The `pastors` collection is populated from `data/pastores_unificado.csv`. **On a new
server, run this once after the first boot** (the schema is created automatically):
```bash
pnpm import:pastors
```
Maps the CSV columns to the collection: `codigo_iso → country` (ISO alpha-2),
`nombre → name`, `ciudad → city`, `estado → state`, `telefono → telephone[]` (numbers
split on ` || `), `email`, `notas → notes`, and `fuente → source`.
Environment flags:
- `DRY_RUN=1` — parse and report the row count without writing anything.
- `CLEAR=1` — delete existing pastors first, then reimport (clean re-run).
- `FORCE=1` — import even if pastors already exist (appends).
Without a flag the script **skips** if any pastors already exist, so it is safe to leave
in a provisioning script — it will not create duplicates on a second run.
## Project structure
```
src/
├── app/
│ ├── (frontend)/ # Public site (Tailwind + daisyUI)
│ │ ├── layout.tsx # Navbar, footer, daisyUI theme (Spanish)
│ │ ├── page.tsx # Home page — pastors data table
│ │ ├── PastorsTable.tsx # Client table + create/edit/upload modals
│ │ ├── actions.ts # Server actions (create/update/upload letter)
│ │ └── styles.css # @import "tailwindcss" + @plugin "daisyui"
│ └── (payload)/ # Payload admin + API (do not edit — generated)
├── collections/
│ ├── Users.ts # Auth-enabled admin users
│ ├── Posts.ts # Title, slug, status, excerpt, cover, tags, richtext
│ ├── Pastors.ts # Name, church, location, phones[], Telegram, letter
│ ├── PastorLetters.ts # Letter uploads (PDF / Word only)
│ ├── Media.ts # File uploads
│ └── Tags.ts
├── payload.config.ts # Payload config (SQLite adapter, collections)
├── import-pastors.ts # CSV → pastors importer (pnpm import:pastors)
└── seed.ts # Sample-content seeder
```
## How the frontend gets content
`src/app/(frontend)/page.tsx` uses Payload's **Local API** directly on the server —
no HTTP round-trip:
```ts
const payload = await getPayload({ config })
const { docs: posts } = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
sort: '-publishedDate',
})
```
Content is also available over REST (`/api/posts`) and GraphQL (`/api/graphql`).
## Styling
Tailwind v4 + daisyUI are configured entirely in CSS (no `tailwind.config.js`):
- `postcss.config.mjs` enables `@tailwindcss/postcss`
- `src/app/(frontend)/styles.css` imports Tailwind and registers daisyUI themes
(`corporate` light / `business` dark)
The Payload admin (route group `(payload)`) uses its own styles and is **not** affected
by Tailwind/daisyUI, because the Tailwind stylesheet is only imported in the frontend
layout.
Change the theme by editing the `data-theme` attribute in
`src/app/(frontend)/layout.tsx`, or add more daisyUI themes in `styles.css`.
## Production build
```bash
pnpm build
pnpm start
```
For production you should switch SQLite from `push` mode to migrations and point
`DATABASE_URI` at a persistent/hosted libSQL database (e.g. Turso). See the
[Payload SQLite docs](https://payloadcms.com/docs/database/sqlite).

1788
data/pastores_unificado.csv Normal file

File diff suppressed because it is too large Load Diff

31
eslint.config.mjs Normal file
View File

@ -0,0 +1,31 @@
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'
import nextTypescript from 'eslint-config-next/typescript'
const eslintConfig = [
...nextCoreWebVitals,
...nextTypescript,
{
rules: {
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{
vars: 'all',
args: 'after-used',
ignoreRestSiblings: false,
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^(_|ignore)',
},
],
},
},
{
ignores: ['.next/', 'src/payload-types.ts', 'src/payload-generated-schema.ts'],
},
]
export default eslintConfig

31
next.config.ts Normal file
View File

@ -0,0 +1,31 @@
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(__filename)
const nextConfig: NextConfig = {
images: {
localPatterns: [
{
pathname: '/api/media/file/**',
},
],
},
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
turbopack: {
root: path.resolve(dirname),
},
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

56
package.json Normal file
View File

@ -0,0 +1,56 @@
{
"name": "db-ministerial",
"version": "1.0.0",
"description": "Payload 3 CMS + Next.js frontend (Tailwind v4 + daisyUI, SQLite)",
"license": "MIT",
"type": "module",
"scripts": {
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint .",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"seed": "cross-env NODE_OPTIONS=--no-deprecation payload run ./src/seed.ts",
"import:pastors": "cross-env NODE_OPTIONS=--no-deprecation payload run ./src/import-pastors.ts",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
},
"dependencies": {
"@payloadcms/db-sqlite": "3.88.0",
"@payloadcms/next": "3.88.0",
"@payloadcms/richtext-lexical": "3.88.0",
"@payloadcms/ui": "3.88.0",
"cross-env": "10.1.0",
"dotenv": "16.4.7",
"graphql": "^16.8.1",
"next": "16.3.1",
"payload": "3.88.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"sharp": "0.34.2"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/node": "24.12.3",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"daisyui": "5.7.16",
"eslint": "^9.16.0",
"eslint-config-next": "16.3.1",
"prettier": "^3.4.2",
"tailwindcss": "4.3.3",
"typescript": "6.0.3"
},
"engines": {
"node": ">=20.9.0",
"pnpm": "^9 || ^10 || ^11"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp",
"esbuild",
"unrs-resolver"
]
}
}

8118
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

17
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,17 @@
allowBuilds:
esbuild: true
sharp: true
unrs-resolver: true
minimumReleaseAgeExclude:
- '@next/env@16.3.1'
- '@next/eslint-plugin-next@16.3.1'
- '@next/swc-darwin-arm64@16.3.1'
- '@next/swc-darwin-x64@16.3.1'
- '@next/swc-linux-arm64-gnu@16.3.1'
- '@next/swc-linux-arm64-musl@16.3.1'
- '@next/swc-linux-x64-gnu@16.3.1'
- '@next/swc-linux-x64-musl@16.3.1'
- '@next/swc-win32-arm64-msvc@16.3.1'
- '@next/swc-win32-x64-msvc@16.3.1'
- eslint-config-next@16.3.1
- next@16.3.1

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config

View File

@ -0,0 +1,382 @@
'use client'
import React, { useRef, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { createPastor, updatePastor, uploadLetter } from './actions'
export type PastorRow = {
id: number
name: string
email: string | null
telephone: string[]
city: string | null
state: string | null
country: string | null
countryName: string | null
countryFlag: string
inTelegram: boolean
notes: string | null
letterUrl: string | null
letterName: string | null
}
export function PastorsTable({ rows }: { rows: PastorRow[] }) {
const [editing, setEditing] = useState<PastorRow | null>(null)
const [uploading, setUploading] = useState<PastorRow | null>(null)
const [creating, setCreating] = useState(false)
return (
<>
<div className="flex justify-end mb-4">
<button type="button" className="btn btn-primary btn-sm" onClick={() => setCreating(true)}>
+ Crear pastor
</button>
</div>
{rows.length === 0 ? (
<div className="card bg-base-100 shadow-sm">
<div className="card-body items-center text-center">
<h3 className="card-title">No se encontraron pastores</h3>
<p className="text-base-content/60">Intenta con otro término de búsqueda.</p>
</div>
</div>
) : (
<div className="overflow-x-auto rounded-box border border-base-300 bg-base-100">
<table className="table table-zebra table-pin-rows">
<thead>
<tr>
<th>Nombre</th>
<th>Correo</th>
<th>Teléfonos</th>
<th>Ciudad</th>
<th>País</th>
<th className="text-center">Telegram</th>
<th>Carta</th>
<th className="text-right">Acciones</th>
</tr>
</thead>
<tbody>
{rows.map((p) => (
<tr key={p.id}>
<td className="font-medium whitespace-nowrap">{p.name}</td>
<td>
{p.email ? (
<a href={`mailto:${p.email}`} className="link link-hover">
{p.email}
</a>
) : (
<span className="opacity-40"></span>
)}
</td>
<td>
{p.telephone.length ? (
<div className="flex flex-col gap-0.5">
{p.telephone.map((t, i) => (
<a key={i} href={`tel:${t}`} className="link link-hover whitespace-nowrap">
{t}
</a>
))}
</div>
) : (
<span className="opacity-40"></span>
)}
</td>
<td className="whitespace-nowrap">{p.city || <span className="opacity-40"></span>}</td>
<td className="whitespace-nowrap">
{p.countryName ? (
<span>
{p.countryFlag} {p.countryName}
</span>
) : (
<span className="opacity-40"></span>
)}
</td>
<td className="text-center">
{p.inTelegram ? (
<span className="badge badge-success badge-sm"></span>
) : (
<span className="badge badge-ghost badge-sm">No</span>
)}
</td>
<td>
{p.letterUrl ? (
<a
href={p.letterUrl}
target="_blank"
rel="noopener noreferrer"
download={p.letterName ?? undefined}
className="btn btn-xs btn-outline btn-success"
>
Descargar
</a>
) : (
<button
type="button"
className="btn btn-xs btn-outline"
onClick={() => setUploading(p)}
>
Subir
</button>
)}
</td>
<td className="text-right">
<button
type="button"
className="btn btn-xs btn-ghost"
onClick={() => setEditing(p)}
>
Editar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{creating && <PastorModal key="create" pastor={null} onClose={() => setCreating(false)} />}
{editing && (
<PastorModal key={`edit-${editing.id}`} pastor={editing} onClose={() => setEditing(null)} />
)}
{uploading && (
<UploadModal
key={`upload-${uploading.id}`}
pastor={uploading}
onClose={() => setUploading(null)}
/>
)}
</>
)
}
function PastorModal({ pastor, onClose }: { pastor: PastorRow | null; onClose: () => void }) {
const router = useRouter()
const isCreate = pastor === null
const [pending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState(pastor?.name ?? '')
const [email, setEmail] = useState(pastor?.email ?? '')
const [phones, setPhones] = useState<string[]>(
pastor?.telephone.length ? pastor.telephone : [''],
)
const [city, setCity] = useState(pastor?.city ?? '')
const [state, setState] = useState(pastor?.state ?? '')
const [country, setCountry] = useState(pastor?.country ?? '')
const [inTelegram, setInTelegram] = useState(pastor?.inTelegram ?? false)
const [notes, setNotes] = useState(pastor?.notes ?? '')
const setPhone = (i: number, v: string) =>
setPhones((prev) => prev.map((p, idx) => (idx === i ? v : p)))
const addPhone = () => setPhones((prev) => [...prev, ''])
const removePhone = (i: number) => setPhones((prev) => prev.filter((_, idx) => idx !== i))
const onSubmit = (e: React.FormEvent) => {
e.preventDefault()
setError(null)
startTransition(async () => {
const input = { name, email, telephone: phones, city, state, country, inTelegram, notes }
const res = isCreate ? await createPastor(input) : await updatePastor(pastor.id, input)
if (res?.ok) {
router.refresh()
onClose()
} else {
setError(
('error' in res && res.error) ||
(isCreate ? 'No se pudo crear el pastor.' : 'No se pudieron guardar los cambios.'),
)
}
})
}
return (
<dialog className="modal modal-open">
<div className="modal-box max-w-xl">
<h3 className="text-lg font-bold pb-3 mb-5 border-b border-base-300">
{isCreate ? 'Crear pastor' : 'Editar pastor'}
</h3>
<form
onSubmit={onSubmit}
className="grid grid-cols-1 sm:grid-cols-[7.5rem_1fr] sm:items-center gap-x-4 gap-y-4"
>
<label htmlFor="f-name" className="text-sm font-medium sm:text-right sm:pr-1">
Nombre
</label>
<input
id="f-name"
className="input input-bordered w-full"
value={name}
placeholder="Nombre completo"
onChange={(e) => setName(e.target.value)}
required
/>
<label htmlFor="f-email" className="text-sm font-medium sm:text-right sm:pr-1">
Correo
</label>
<input
id="f-email"
type="email"
className="input input-bordered w-full"
value={email}
placeholder="correo@ejemplo.com"
onChange={(e) => setEmail(e.target.value)}
/>
<span className="text-sm font-medium sm:text-right sm:pr-1 sm:self-start sm:pt-3">
Teléfonos
</span>
<div className="flex flex-col gap-2">
{phones.map((p, i) => (
<div key={i} className="join w-full">
<input
className="input input-bordered join-item w-full"
value={p}
placeholder="+1 555 123 4567"
onChange={(e) => setPhone(i, e.target.value)}
/>
<button
type="button"
className="btn btn-outline join-item"
onClick={() => removePhone(i)}
aria-label="Eliminar número"
>
</button>
</div>
))}
<button type="button" className="btn btn-sm btn-ghost self-start" onClick={addPhone}>
+ Agregar número
</button>
</div>
<label htmlFor="f-city" className="text-sm font-medium sm:text-right sm:pr-1">
Ciudad
</label>
<input
id="f-city"
className="input input-bordered w-full"
value={city}
onChange={(e) => setCity(e.target.value)}
/>
<label htmlFor="f-state" className="text-sm font-medium sm:text-right sm:pr-1">
Estado
</label>
<input
id="f-state"
className="input input-bordered w-full"
value={state}
onChange={(e) => setState(e.target.value)}
/>
<label htmlFor="f-country" className="text-sm font-medium sm:text-right sm:pr-1">
País
</label>
<div className="flex items-center gap-2">
<input
id="f-country"
className="input input-bordered w-24 uppercase"
value={country}
maxLength={2}
placeholder="MX"
onChange={(e) => setCountry(e.target.value)}
/>
<span className="text-xs text-base-content/50">Código ISO de 2 letras</span>
</div>
<span className="text-sm font-medium sm:text-right sm:pr-1">En Telegram</span>
<input
type="checkbox"
className="toggle toggle-success"
checked={inTelegram}
onChange={(e) => setInTelegram(e.target.checked)}
/>
<label htmlFor="f-notes" className="text-sm font-medium sm:text-right sm:pr-1 sm:self-start sm:pt-3">
Notas
</label>
<textarea
id="f-notes"
className="textarea textarea-bordered w-full"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
{error && (
<div role="alert" className="alert alert-error alert-soft sm:col-span-2 py-2">
<span className="text-sm">{error}</span>
</div>
)}
<div className="modal-action sm:col-span-2 mt-2">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={pending}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={pending}>
{pending && <span className="loading loading-spinner loading-sm" />}
{isCreate ? 'Crear' : 'Guardar'}
</button>
</div>
</form>
</div>
<button type="button" className="modal-backdrop" onClick={onClose} aria-label="Cerrar" />
</dialog>
)
}
function UploadModal({ pastor, onClose }: { pastor: PastorRow; onClose: () => void }) {
const router = useRouter()
const formRef = useRef<HTMLFormElement>(null)
const [pending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const onSubmit = (e: React.FormEvent) => {
e.preventDefault()
setError(null)
const formData = new FormData(formRef.current!)
startTransition(async () => {
const res = await uploadLetter(pastor.id, formData)
if (res?.ok) {
router.refresh()
onClose()
} else {
setError(res?.error ?? 'Error al subir el archivo.')
}
})
}
return (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="text-lg font-bold">Subir carta</h3>
<p className="text-sm text-base-content/60 mt-1 mb-4">
Adjunta una carta para <span className="font-medium">{pastor.name}</span>. Solo PDF o Word.
</p>
<form ref={formRef} onSubmit={onSubmit} className="flex flex-col gap-4">
<input
type="file"
name="file"
accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
className="file-input file-input-bordered w-full"
required
/>
{error && <p className="text-error text-sm">{error}</p>}
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={pending}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={pending}>
{pending && <span className="loading loading-spinner loading-sm" />}
Subir
</button>
</div>
</form>
</div>
<button type="button" className="modal-backdrop" onClick={onClose} aria-label="Cerrar" />
</dialog>
)
}

View File

@ -0,0 +1,109 @@
'use server'
import { getPayload } from 'payload'
import { revalidatePath } from 'next/cache'
import config from '@/payload.config'
export type PastorUpdateInput = {
name: string
email?: string | null
telephone?: string[]
city?: string | null
state?: string | null
country?: string | null
inTelegram?: boolean
notes?: string | null
}
/** Crea un nuevo pastor desde el modal de creación del frontend. */
export async function createPastor(data: PastorUpdateInput) {
if (!data.name?.trim()) {
return { ok: false, error: 'El nombre es obligatorio.' }
}
const payload = await getPayload({ config: await config })
const doc = await payload.create({
collection: 'pastors',
data: {
name: data.name.trim(),
email: data.email || undefined,
telephone: (data.telephone ?? []).map((t) => t.trim()).filter(Boolean),
city: data.city || undefined,
state: data.state || undefined,
country: data.country ? data.country.trim().toUpperCase() : undefined,
inTelegram: Boolean(data.inTelegram),
notes: data.notes || undefined,
},
})
revalidatePath('/')
return { ok: true, id: doc.id }
}
/** Actualiza un pastor existente desde el modal de edición del frontend. */
export async function updatePastor(id: number, data: PastorUpdateInput) {
const payload = await getPayload({ config: await config })
await payload.update({
collection: 'pastors',
id,
data: {
name: data.name,
email: data.email || undefined,
telephone: (data.telephone ?? []).map((t) => t.trim()).filter(Boolean),
city: data.city || undefined,
state: data.state || undefined,
country: data.country ? data.country.trim().toUpperCase() : undefined,
inTelegram: Boolean(data.inTelegram),
notes: data.notes || undefined,
},
})
revalidatePath('/')
return { ok: true }
}
/**
* Sube una carta (PDF o Word) y la asocia a un pastor. El archivo se guarda en
* la colección de subidas `pastor-letters`, que valida los tipos de archivo
* permitidos, y luego se enlaza desde el campo `letter` del pastor.
*/
export async function uploadLetter(pastorId: number, formData: FormData) {
const file = formData.get('file')
if (!(file instanceof File) || file.size === 0) {
return { ok: false, error: 'No se proporcionó ningún archivo.' }
}
const payload = await getPayload({ config: await config })
const buffer = Buffer.from(await file.arrayBuffer())
try {
const letter = await payload.create({
collection: 'pastor-letters',
data: {},
file: {
data: buffer,
mimetype: file.type,
name: file.name,
size: file.size,
},
})
await payload.update({
collection: 'pastors',
id: pastorId,
data: { letter: letter.id },
})
} catch (err) {
return {
ok: false,
error:
err instanceof Error ? err.message : 'Error al subir. Solo se permiten archivos PDF o Word.',
}
}
revalidatePath('/')
return { ok: true }
}

View File

@ -0,0 +1,54 @@
import React from 'react'
import Link from 'next/link'
import './styles.css'
export const metadata = {
description: 'Base de datos ministerial — desarrollada con Payload CMS + Next.js.',
title: 'DB Ministerial',
}
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
return (
<html lang="es" data-theme="corporate">
<body className="min-h-screen flex flex-col bg-base-200">
<header>
<div className="navbar bg-base-100 shadow-sm">
<div className="navbar-start">
<Link href="/" className="btn btn-ghost text-xl font-bold">
DB Ministerial
</Link>
</div>
<div className="navbar-center hidden sm:flex">
<ul className="menu menu-horizontal px-1">
<li>
<Link href="/">Pastores</Link>
</li>
<li>
<a href="/admin">Administración</a>
</li>
</ul>
</div>
<div className="navbar-end">
<a href="/admin" className="btn btn-primary btn-sm">
Iniciar sesión
</a>
</div>
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="footer footer-center bg-base-100 text-base-content p-6 mt-auto">
<aside>
<p className="font-semibold">DB Ministerial</p>
<p className="text-sm opacity-70">
Desarrollado con Payload CMS, Next.js, Tailwind CSS y daisyUI.
</p>
</aside>
</footer>
</body>
</html>
)
}

136
src/app/(frontend)/page.tsx Normal file
View File

@ -0,0 +1,136 @@
import { getPayload } from 'payload'
import React from 'react'
import config from '@/payload.config'
import './styles.css'
import { PastorsTable, type PastorRow } from './PastorsTable'
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 25
// Idioma usado para mostrar los códigos ISO de país como nombres legibles.
const DISPLAY_LOCALE = 'es'
const regionNames = new Intl.DisplayNames([DISPLAY_LOCALE], { type: 'region' })
const localizedCountry = (code?: string | null): string | null => {
if (!code) return null
try {
return regionNames.of(code.toUpperCase()) ?? code
} catch {
return code
}
}
// Código ISO alpha-2 -> emoji de bandera (símbolos indicadores regionales).
const flag = (code?: string | null): string => {
if (!code || code.length !== 2) return ''
const base = 0x1f1e6
const cc = code.toUpperCase()
return String.fromCodePoint(base + (cc.charCodeAt(0) - 65), base + (cc.charCodeAt(1) - 65))
}
export default async function HomePage({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>
}) {
const { q = '', page = '1' } = await searchParams
const currentPage = Math.max(1, parseInt(page, 10) || 1)
const search = q.trim()
const payload = await getPayload({ config: await config })
const where = search
? {
or: [
{ name: { like: search } },
{ email: { like: search } },
{ city: { like: search } },
{ country: { like: search } },
],
}
: {}
const result = await payload.find({
collection: 'pastors',
where,
sort: 'name',
depth: 1,
page: currentPage,
limit: PAGE_SIZE,
})
const rows: PastorRow[] = result.docs.map((p) => {
const letter = p.letter && typeof p.letter === 'object' ? p.letter : null
return {
id: p.id,
name: p.name,
email: p.email ?? null,
telephone: Array.isArray(p.telephone) ? p.telephone.filter(Boolean) : [],
city: p.city ?? null,
state: p.state ?? null,
country: p.country ?? null,
countryName: localizedCountry(p.country),
countryFlag: flag(p.country),
inTelegram: Boolean(p.inTelegram),
notes: p.notes ?? null,
letterUrl: letter?.url ?? null,
letterName: letter?.filename ?? null,
}
})
return (
<section className="max-w-7xl mx-auto w-full px-4 py-10">
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
<div>
<h1 className="text-3xl font-bold">Pastores</h1>
<p className="text-base-content/60 text-sm mt-1">
{result.totalDocs.toLocaleString('es')} registros
</p>
</div>
<form method="get" className="join">
<input
type="text"
name="q"
defaultValue={search}
placeholder="Buscar nombre, correo, ciudad, país…"
className="input input-bordered join-item w-64 max-w-full"
/>
<button type="submit" className="btn btn-primary join-item">
Buscar
</button>
{search && (
<a href="/" className="btn btn-ghost join-item">
Limpiar
</a>
)}
</form>
</div>
<PastorsTable rows={rows} />
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-base-content/60">
Página {result.page} de {result.totalPages}
</p>
<div className="join">
<a
href={`/?${new URLSearchParams({ ...(search ? { q: search } : {}), page: String(currentPage - 1) })}`}
className={`btn join-item ${result.hasPrevPage ? '' : 'btn-disabled'}`}
aria-disabled={!result.hasPrevPage}
>
« Anterior
</a>
<a
href={`/?${new URLSearchParams({ ...(search ? { q: search } : {}), page: String(currentPage + 1) })}`}
className={`btn join-item ${result.hasNextPage ? '' : 'btn-disabled'}`}
aria-disabled={!result.hasNextPage}
>
Siguiente »
</a>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,10 @@
@import 'tailwindcss';
@plugin "daisyui" {
themes:
corporate --default,
business --prefersdark;
}
/* Scoped to the public frontend only. The Payload admin (route group
"(payload)") loads its own styles and is unaffected by Tailwind/daisyUI. */

View File

@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap })
export default NotFound

View File

@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page

View File

@ -0,0 +1,52 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}

View File

@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

View File

@ -0,0 +1,31 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.css'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

12
src/app/my-route/route.ts Normal file
View File

@ -0,0 +1,12 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export const GET = async (request: Request) => {
const payload = await getPayload({
config: configPromise,
})
return Response.json({
message: 'This is an example of a custom route.',
})
}

16
src/collections/Media.ts Normal file
View File

@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
access: {
read: () => true,
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
],
upload: true,
}

View File

@ -0,0 +1,24 @@
import type { CollectionConfig } from 'payload'
export const PastorLetters: CollectionConfig = {
slug: 'pastor-letters',
admin: {
useAsTitle: 'filename',
},
access: {
read: () => true,
},
fields: [
{
name: 'alt',
type: 'text',
},
],
upload: {
mimeTypes: [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
},
}

View File

@ -0,0 +1,87 @@
import type { CollectionConfig } from 'payload'
export const Pastors: CollectionConfig = {
slug: 'pastors',
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'churchName', 'city', 'country', 'updatedAt'],
},
access: {
read: () => true,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'churchName',
label: 'Church Name',
type: 'text',
},
{
name: 'city',
type: 'text',
},
{
name: 'state',
type: 'text',
},
{
name: 'country',
type: 'text',
admin: {
description: 'ISO 3166-1 alpha-2 country code, e.g. MX, BR, US.',
},
},
{
name: 'impactedPeople',
label: 'Impacted People',
type: 'number',
min: 0,
},
{
name: 'registrationNumber',
label: 'Registration Number',
type: 'text',
},
{
name: 'inTelegram',
label: 'In Telegram',
type: 'checkbox',
defaultValue: false,
},
{
name: 'telephone',
label: 'Telephone (with international country code)',
type: 'text',
hasMany: true,
admin: {
description: 'One or more numbers, each with the international country code, e.g. +1 555 123 4567.',
},
},
{
name: 'email',
type: 'email',
},
{
name: 'notes',
type: 'textarea',
},
{
name: 'source',
type: 'text',
admin: {
description: 'Origin of this record, e.g. the source file it was imported from.',
position: 'sidebar',
},
},
{
name: 'letter',
label: 'Letter Upload (PDF or Word)',
type: 'upload',
relationTo: 'pastor-letters',
},
],
}

91
src/collections/Posts.ts Normal file
View File

@ -0,0 +1,91 @@
import type { CollectionConfig } from 'payload'
const slugify = (value: string): string =>
value
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'status', 'publishedDate', 'updatedAt'],
},
access: {
// Public read access so the frontend can render posts without auth.
read: () => true,
},
hooks: {
beforeValidate: [
({ data }) => {
if (data && !data.slug && typeof data.title === 'string') {
data.slug = slugify(data.title)
}
return data
},
],
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
unique: true,
index: true,
admin: {
description: 'Auto-generated from the title if left blank.',
position: 'sidebar',
},
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'publishedDate',
type: 'date',
admin: {
position: 'sidebar',
date: { pickerAppearance: 'dayOnly' },
},
},
{
name: 'excerpt',
type: 'textarea',
admin: {
description: 'Short summary shown on the home page cards.',
},
},
{
name: 'coverImage',
type: 'upload',
relationTo: 'media',
},
{
name: 'tags',
type: 'relationship',
relationTo: 'tags',
hasMany: true,
},
{
name: 'content',
type: 'richText',
},
],
}

18
src/collections/Tags.ts Normal file
View File

@ -0,0 +1,18 @@
import type { CollectionConfig } from 'payload'
export const Tags: CollectionConfig = {
slug: 'tags',
admin: {
useAsTitle: 'name',
},
access: {
read: () => true,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
],
}

14
src/collections/Users.ts Normal file
View File

@ -0,0 +1,14 @@
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
fields: [
// Email added by default
// Add more fields as needed
],
versions: false,
}

147
src/import-pastors.ts Normal file
View File

@ -0,0 +1,147 @@
import { readFileSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { getPayload } from 'payload'
import config from './payload.config'
/**
* Imports pastor records from data/pastores_unificado.csv into the `pastors`
* collection. Run with: pnpm tsx ... or payload run ./src/import-pastors.ts
*
* Safe to inspect before committing: pass DRY_RUN=1 to parse and report
* without writing. Skips the import if any pastors already exist unless
* FORCE=1 is set (to avoid creating duplicates on an accidental re-run).
*/
const dirname = path.dirname(fileURLToPath(import.meta.url))
const csvPath = path.resolve(dirname, '../data/pastores_unificado.csv')
/** Minimal RFC-4180 CSV parser: handles quoted fields, embedded commas,
* newlines, and escaped ("") quotes. Returns an array of string arrays. */
function parseCsv(text: string): string[][] {
const rows: string[][] = []
let row: string[] = []
let field = ''
let inQuotes = false
let i = 0
const pushField = () => {
row.push(field)
field = ''
}
const pushRow = () => {
pushField()
rows.push(row)
row = []
}
while (i < text.length) {
const c = text[i]
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') {
field += '"'
i += 2
} else {
inQuotes = false
i++
}
} else {
field += c
i++
}
} else if (c === '"') {
inQuotes = true
i++
} else if (c === ',') {
pushField()
i++
} else if (c === '\r') {
i++
} else if (c === '\n') {
pushRow()
i++
} else {
field += c
i++
}
}
// flush trailing field/row if the file doesn't end with a newline
if (field.length > 0 || row.length > 0) pushRow()
return rows
}
const clean = (v: string | undefined): string | undefined => {
const t = (v ?? '').trim()
return t.length ? t : undefined
}
const payload = await getPayload({ config: await config })
const existing = await payload.count({ collection: 'pastors' })
if (existing.totalDocs > 0 && process.env.CLEAR === '1') {
payload.logger.info(`Clearing ${existing.totalDocs} existing pastors…`)
await payload.delete({ collection: 'pastors', where: { id: { exists: true } } })
} else if (existing.totalDocs > 0 && process.env.FORCE !== '1') {
payload.logger.info(
`${existing.totalDocs} pastors already exist — skipping import. Set CLEAR=1 to wipe and reimport, or FORCE=1 to append.`,
)
process.exit(0)
}
const raw = readFileSync(csvPath, 'utf-8')
const table = parseCsv(raw)
const header = table[0].map((h) => h.trim())
const dataRows = table.slice(1).filter((r) => r.some((c) => c.trim().length))
const idx = (name: string) => header.indexOf(name)
const col = {
codigoIso: idx('codigo_iso'),
nombre: idx('nombre'),
ciudad: idx('ciudad'),
estado: idx('estado'),
telefono: idx('telefono'),
email: idx('email'),
notas: idx('notas'),
fuente: idx('fuente'),
}
/** Split a telefono cell (numbers joined by ' || ') into a trimmed array. */
const parsePhones = (v: string | undefined): string[] =>
(v ?? '')
.split('||')
.map((p) => p.trim())
.filter((p) => p.length > 0)
const dryRun = process.env.DRY_RUN === '1'
payload.logger.info(`Parsed ${dataRows.length} data rows from ${path.basename(csvPath)}`)
let created = 0
let skipped = 0
for (const r of dataRows) {
const name = clean(r[col.nombre])
if (!name) {
skipped++
continue
}
const data = {
name,
country: clean(r[col.codigoIso]),
city: clean(r[col.ciudad]),
state: clean(r[col.estado]),
telephone: parsePhones(r[col.telefono]),
email: clean(r[col.email]),
notes: clean(r[col.notas]),
source: clean(r[col.fuente]),
}
if (dryRun) {
created++
continue
}
await payload.create({ collection: 'pastors', data })
created++
if (created % 200 === 0) payload.logger.info(`${created} created`)
}
payload.logger.info(
`${dryRun ? 'DRY RUN — ' : ''}Done. ${created} pastors imported, ${skipped} skipped (missing name).`,
)
process.exit(0)

524
src/payload-types.ts Normal file
View File

@ -0,0 +1,524 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
posts: Post;
media: Media;
tags: Tag;
pastors: Pastor;
'pastor-letters': PastorLetter;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
tags: TagsSelect<false> | TagsSelect<true>;
pastors: PastorsSelect<false> | PastorsSelect<true>;
'pastor-letters': PastorLettersSelect<false> | PastorLettersSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
fallbackLocale: ('false' | 'none' | 'null') | false | null | 'en' | 'en'[];
globals: {};
globalsSelect: {};
locale: 'en';
widgets: {
collections: CollectionsWidget;
};
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'users';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: number;
title: string;
/**
* Auto-generated from the title if left blank.
*/
slug?: string | null;
status: 'draft' | 'published';
publishedDate?: string | null;
/**
* Short summary shown on the home page cards.
*/
excerpt?: string | null;
coverImage?: (number | null) | Media;
tags?: (number | Tag)[] | null;
content?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tags".
*/
export interface Tag {
id: number;
name: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pastors".
*/
export interface Pastor {
id: number;
name: string;
churchName?: string | null;
city?: string | null;
state?: string | null;
/**
* ISO 3166-1 alpha-2 country code, e.g. MX, BR, US.
*/
country?: string | null;
impactedPeople?: number | null;
registrationNumber?: string | null;
inTelegram?: boolean | null;
/**
* One or more numbers, each with the international country code, e.g. +1 555 123 4567.
*/
telephone?: string[] | null;
email?: string | null;
notes?: string | null;
/**
* Origin of this record, e.g. the source file it was imported from.
*/
source?: string | null;
letter?: (number | null) | PastorLetter;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pastor-letters".
*/
export interface PastorLetter {
id: number;
alt?: string | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: number;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'posts';
value: number | Post;
} | null)
| ({
relationTo: 'media';
value: number | Media;
} | null)
| ({
relationTo: 'tags';
value: number | Tag;
} | null)
| ({
relationTo: 'pastors';
value: number | Pastor;
} | null)
| ({
relationTo: 'pastor-letters';
value: number | PastorLetter;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
slug?: T;
status?: T;
publishedDate?: T;
excerpt?: T;
coverImage?: T;
tags?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tags_select".
*/
export interface TagsSelect<T extends boolean = true> {
name?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pastors_select".
*/
export interface PastorsSelect<T extends boolean = true> {
name?: T;
churchName?: T;
city?: T;
state?: T;
country?: T;
impactedPeople?: T;
registrationNumber?: T;
inTelegram?: T;
telephone?: T;
email?: T;
notes?: T;
source?: T;
letter?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pastor-letters_select".
*/
export interface PastorLettersSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
*/
export interface CollectionsWidget {
data?: {
[k: string]: unknown;
};
width: 'full';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

43
src/payload.config.ts Normal file
View File

@ -0,0 +1,43 @@
import { sqliteAdapter } from '@payloadcms/db-sqlite'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Tags } from './collections/Tags'
import { Posts } from './collections/Posts'
import { Pastors } from './collections/Pastors'
import { PastorLetters } from './collections/PastorLetters'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Users, Posts, Media, Tags, Pastors, PastorLetters],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: sqliteAdapter({
client: {
url: process.env.DATABASE_URI || 'file:./ministerial.db',
},
}),
sharp,
localization: {
locales: ['en'],
fallback: true,
defaultLocale: 'en',
},
plugins: [],
})

67
src/seed.ts Normal file
View File

@ -0,0 +1,67 @@
import { getPayload } from 'payload'
import config from './payload.config'
/**
* Seeds an admin user, a few tags, and sample published posts.
* Run with: pnpm seed
*
* NOTE: uses top-level await `payload run` exits once module evaluation
* settles, so the work must be awaited at the top level (not in a floating
* promise). Safe to re-run: it skips seeding if any user already exists.
*/
const payload = await getPayload({ config: await config })
const existing = await payload.find({ collection: 'users', limit: 1 })
if (existing.totalDocs > 0) {
payload.logger.info('Users already exist — skipping seed.')
process.exit(0)
}
const email = process.env.SEED_ADMIN_EMAIL || 'admin@example.com'
const password = process.env.SEED_ADMIN_PASSWORD || 'changeme123'
await payload.create({ collection: 'users', data: { email, password } })
payload.logger.info(`Created admin user: ${email} / ${password}`)
const tagNames = ['Announcements', 'Events', 'Resources']
const tagIds: Record<string, number | string> = {}
for (const name of tagNames) {
const tag = await payload.create({ collection: 'tags', data: { name } })
tagIds[name] = tag.id
}
const samplePosts = [
{
title: 'Welcome to DB Ministerial',
excerpt:
'This starter pairs Payload CMS with a Next.js frontend styled using Tailwind CSS and daisyUI.',
tags: [tagIds['Announcements']],
},
{
title: 'How content flows to the frontend',
excerpt:
'Create a Post in the admin panel, set its status to Published, and it appears on the home page instantly.',
tags: [tagIds['Resources']],
},
{
title: 'Upcoming community gathering',
excerpt: 'A sample event post showing tags, dates, and excerpts rendered as daisyUI cards.',
tags: [tagIds['Events']],
},
]
for (const post of samplePosts) {
await payload.create({
collection: 'posts',
data: {
title: post.title,
excerpt: post.excerpt,
status: 'published',
publishedDate: new Date().toISOString(),
tags: post.tags as (number | string)[],
},
})
}
payload.logger.info(`Created ${samplePosts.length} published posts.`)
process.exit(0)

44
tsconfig.json Normal file
View File

@ -0,0 +1,44 @@
{
"compilerOptions": {
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
],
"@payload-config": [
"./src/payload.config.ts"
]
},
"target": "ES2022"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}