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:
commit
e1bd3c7949
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": ["dev"],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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/
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"semi": false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||||
|
}
|
||||||
|
|
@ -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}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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).
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||||
|
|
@ -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 })
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
|
|
@ -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">Sí</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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. */
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 {}
|
||||||
|
}
|
||||||
|
|
@ -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: [],
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue