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