117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
import type { AccessArgs, CollectionConfig } from 'payload'
|
|
|
|
const isAdmin = (args: AccessArgs) =>
|
|
(args.req.user as { role?: string } | null | undefined)?.role === 'admin'
|
|
|
|
export const CATEGORIES = ['وكلاء', 'نماذج', 'استرجاع', 'سلامة', 'أدوات', 'أعمال'] as const
|
|
|
|
const Posts: CollectionConfig = {
|
|
slug: 'posts',
|
|
admin: {
|
|
useAsTitle: 'title',
|
|
defaultColumns: ['title', 'slug', 'category', 'access', 'status', 'publishedAt'],
|
|
},
|
|
access: {
|
|
admin: isAdmin,
|
|
read: ({ req }) => {
|
|
if (req.user?.role === 'admin') return true
|
|
// Public: only published posts (per-doc filtering happens in the hook below)
|
|
return { status: { equals: 'published' } }
|
|
},
|
|
create: isAdmin,
|
|
update: isAdmin,
|
|
delete: isAdmin,
|
|
},
|
|
fields: [
|
|
{ name: 'title', type: 'text', required: true },
|
|
{
|
|
name: 'slug',
|
|
type: 'text',
|
|
required: true,
|
|
unique: true,
|
|
index: true,
|
|
admin: { description: 'Latin slug, used in /posts/[slug]' },
|
|
},
|
|
{ name: 'dek', type: 'textarea', required: true, label: 'Standfirst' },
|
|
{
|
|
name: 'access',
|
|
type: 'select',
|
|
required: true,
|
|
defaultValue: 'free',
|
|
options: [
|
|
{ label: 'مجاني', value: 'free' },
|
|
{ label: 'VIP', value: 'vip' },
|
|
],
|
|
},
|
|
{
|
|
name: 'teaser',
|
|
type: 'textarea',
|
|
label: 'Teaser (free preview for VIP posts)',
|
|
admin: { condition: (_data, siblingData: { access?: string } | undefined) => siblingData?.access === 'vip' },
|
|
validate: (value: unknown, { siblingData }: { siblingData?: { access?: string } }) => {
|
|
if (siblingData?.access === 'vip' && (!value || String(value).trim() === ''))
|
|
return 'Teaser is required for VIP posts'
|
|
return true
|
|
},
|
|
},
|
|
{
|
|
name: 'content',
|
|
type: 'richText',
|
|
required: true,
|
|
access: {
|
|
// Never serve VIP body to non-VIP clients — defense in depth
|
|
// (the paywall page also never queries it for them).
|
|
read: ((args: { doc?: { access?: string }; req: { user?: { role?: string } | null } }) => {
|
|
if (!args.doc) return true
|
|
if (args.doc.access !== 'vip') return true
|
|
return args.req.user?.role === 'vip' || args.req.user?.role === 'admin'
|
|
}) as never, // field-permission signature is structurally compatible; TS can't infer it here
|
|
},
|
|
},
|
|
{
|
|
// Per-post work files. Deliberately not field-gated: ids/filenames are
|
|
// harmless metadata — the bytes are gated by GET /api/files/[id] +
|
|
// each file's own `access` (see collections/Files.ts).
|
|
name: 'attachments',
|
|
type: 'upload',
|
|
relationTo: 'files',
|
|
hasMany: true,
|
|
label: 'ملفات العمل (VIP)',
|
|
},
|
|
{
|
|
name: 'category',
|
|
type: 'select',
|
|
required: true,
|
|
options: CATEGORIES.map((c) => ({ label: c, value: c })),
|
|
},
|
|
{
|
|
name: 'level',
|
|
type: 'select',
|
|
required: true,
|
|
defaultValue: 'مبتدئ',
|
|
options: [
|
|
{ label: 'مبتدئ', value: 'مبتدئ' },
|
|
{ label: 'متوسط', value: 'متوسط' },
|
|
{ label: 'متقدم', value: 'متقدم' },
|
|
],
|
|
},
|
|
{ name: 'views', type: 'number', defaultValue: 0, admin: { readOnly: true, position: 'sidebar' } },
|
|
{ name: 'likes', type: 'number', defaultValue: 0, admin: { readOnly: true, position: 'sidebar' } },
|
|
{ name: 'cover', type: 'upload', relationTo: 'media', required: true },
|
|
{ name: 'readingMinutes', type: 'number', required: true, defaultValue: 5 },
|
|
{ name: 'publishedAt', type: 'date', admin: { position: 'sidebar' } },
|
|
{
|
|
name: 'status',
|
|
type: 'select',
|
|
required: true,
|
|
defaultValue: 'draft',
|
|
options: [
|
|
{ label: 'Draft', value: 'draft' },
|
|
{ label: 'Published', value: 'published' },
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
export default Posts
|