kilo-cms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -0
- package/drizzle.kilo.config.ts +17 -0
- package/package.json +97 -0
- package/src/admin/AiSettingsEditor.tsx +48 -0
- package/src/admin/AiWritingAssistant.tsx +159 -0
- package/src/admin/ApiTokensEditor.tsx +69 -0
- package/src/admin/ArrayField.tsx +118 -0
- package/src/admin/BlocksField.tsx +131 -0
- package/src/admin/Button.tsx +31 -0
- package/src/admin/CloseButton.tsx +18 -0
- package/src/admin/CmsLocalesProvider.tsx +19 -0
- package/src/admin/CmsSidebar.tsx +147 -0
- package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
- package/src/admin/CmsThemeProvider.tsx +100 -0
- package/src/admin/CollectionListEditor.tsx +278 -0
- package/src/admin/CollectionRecordEditor.tsx +420 -0
- package/src/admin/ContentEditorForm.tsx +21 -0
- package/src/admin/ContentViewSettings.tsx +55 -0
- package/src/admin/DashboardBuilder.tsx +284 -0
- package/src/admin/DragHandle.tsx +3 -0
- package/src/admin/DynamicField.tsx +517 -0
- package/src/admin/EditorHeader.tsx +13 -0
- package/src/admin/EditorSkeleton.tsx +27 -0
- package/src/admin/EmailSettingsEditor.tsx +80 -0
- package/src/admin/Field.tsx +215 -0
- package/src/admin/FilterBuilder.tsx +62 -0
- package/src/admin/GroupField.tsx +61 -0
- package/src/admin/ImageUploader.tsx +198 -0
- package/src/admin/ListingSettingsModal.tsx +68 -0
- package/src/admin/ListingView.tsx +123 -0
- package/src/admin/LocalesEditor.tsx +86 -0
- package/src/admin/LocalizedField.tsx +46 -0
- package/src/admin/MapPicker.tsx +53 -0
- package/src/admin/MediaLibrary.tsx +483 -0
- package/src/admin/MediaUploader.tsx +55 -0
- package/src/admin/Portal.tsx +10 -0
- package/src/admin/PublishingPanel.tsx +102 -0
- package/src/admin/RecordMeta.tsx +24 -0
- package/src/admin/ReviewQueueEditor.tsx +103 -0
- package/src/admin/RichTextEditor.tsx +49 -0
- package/src/admin/RoleEditor.tsx +121 -0
- package/src/admin/RolesEditor.tsx +42 -0
- package/src/admin/RowActionsMenu.tsx +64 -0
- package/src/admin/ScheduledEditor.tsx +155 -0
- package/src/admin/SeoSettingsEditor.tsx +53 -0
- package/src/admin/SingleRecordEditor.tsx +215 -0
- package/src/admin/SiteSettingsEditor.tsx +96 -0
- package/src/admin/Toast.tsx +45 -0
- package/src/admin/UserEditor.tsx +64 -0
- package/src/admin/UsersEditor.tsx +37 -0
- package/src/admin/WebhooksEditor.tsx +115 -0
- package/src/admin/admin.css +6637 -0
- package/src/admin/globals.css +14 -0
- package/src/admin/pages/admin-index.tsx +5 -0
- package/src/admin/pages/ai-settings.tsx +9 -0
- package/src/admin/pages/api-tokens.tsx +10 -0
- package/src/admin/pages/collection-list.tsx +22 -0
- package/src/admin/pages/collection-record.tsx +31 -0
- package/src/admin/pages/dashboard.tsx +10 -0
- package/src/admin/pages/email-settings.tsx +10 -0
- package/src/admin/pages/layout.tsx +24 -0
- package/src/admin/pages/locales-settings.tsx +10 -0
- package/src/admin/pages/login.tsx +25 -0
- package/src/admin/pages/media.tsx +9 -0
- package/src/admin/pages/review-queue.tsx +20 -0
- package/src/admin/pages/role-edit.tsx +11 -0
- package/src/admin/pages/roles.tsx +10 -0
- package/src/admin/pages/scheduled.tsx +17 -0
- package/src/admin/pages/seo-settings.tsx +9 -0
- package/src/admin/pages/setup-admin.tsx +76 -0
- package/src/admin/pages/single-record.tsx +24 -0
- package/src/admin/pages/site-settings.tsx +9 -0
- package/src/admin/pages/user-edit.tsx +11 -0
- package/src/admin/pages/user-new.tsx +10 -0
- package/src/admin/pages/users.tsx +10 -0
- package/src/admin/pages/webhooks-settings.tsx +10 -0
- package/src/admin/useKeyedRows.ts +35 -0
- package/src/ai-config.ts +35 -0
- package/src/ai-secrets.ts +17 -0
- package/src/ai-types.ts +10 -0
- package/src/ai-writer.ts +84 -0
- package/src/api-tokens.ts +25 -0
- package/src/auth-client.ts +5 -0
- package/src/auth.ts +33 -0
- package/src/calendar-grid.ts +12 -0
- package/src/cli/index.mjs +149 -0
- package/src/cms-appearance.ts +55 -0
- package/src/collections/content-view.ts +69 -0
- package/src/collections/define.ts +17 -0
- package/src/collections/filters.ts +92 -0
- package/src/collections/index.ts +11 -0
- package/src/collections/join.ts +64 -0
- package/src/collections/locale.ts +22 -0
- package/src/collections/query.ts +160 -0
- package/src/collections/registry.ts +88 -0
- package/src/collections/schedule.ts +12 -0
- package/src/collections/server.ts +12 -0
- package/src/collections/single.ts +26 -0
- package/src/collections/table.ts +14 -0
- package/src/collections/types.ts +336 -0
- package/src/collections/validation.ts +217 -0
- package/src/collections/values.ts +181 -0
- package/src/collections/workflow.ts +90 -0
- package/src/config.ts +34 -0
- package/src/content-types.ts +218 -0
- package/src/dashboard.ts +39 -0
- package/src/db.ts +50 -0
- package/src/email.ts +39 -0
- package/src/format.ts +4 -0
- package/src/listQuery.ts +23 -0
- package/src/locale-settings.ts +23 -0
- package/src/media-storage.ts +19 -0
- package/src/permissions.ts +37 -0
- package/src/publishing-workflow-db.ts +11 -0
- package/src/publishing-workflow.ts +20 -0
- package/src/richtext.ts +5 -0
- package/src/routes/admin/ai/settings/route.ts +20 -0
- package/src/routes/admin/ai/write/route.ts +21 -0
- package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
- package/src/routes/admin/api-tokens/route.ts +26 -0
- package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
- package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
- package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
- package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
- package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
- package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
- package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
- package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/route.ts +88 -0
- package/src/routes/admin/collections/route.ts +13 -0
- package/src/routes/admin/content-views/[type]/route.ts +25 -0
- package/src/routes/admin/dashboard/data/route.ts +39 -0
- package/src/routes/admin/dashboard/route.ts +30 -0
- package/src/routes/admin/email-settings/route.ts +49 -0
- package/src/routes/admin/email-settings/test/route.ts +18 -0
- package/src/routes/admin/locales/[code]/route.ts +38 -0
- package/src/routes/admin/locales/reorder/route.ts +18 -0
- package/src/routes/admin/locales/route.ts +46 -0
- package/src/routes/admin/media/[id]/restore/route.ts +13 -0
- package/src/routes/admin/media/[id]/route.ts +56 -0
- package/src/routes/admin/media/bulk/route.ts +46 -0
- package/src/routes/admin/media/folders/[id]/route.ts +34 -0
- package/src/routes/admin/media/route.ts +41 -0
- package/src/routes/admin/media/stats/route.ts +14 -0
- package/src/routes/admin/roles/[id]/route.ts +43 -0
- package/src/routes/admin/roles/route.ts +34 -0
- package/src/routes/admin/settings/route.ts +45 -0
- package/src/routes/admin/single/[type]/route.ts +54 -0
- package/src/routes/admin/singles/route.ts +15 -0
- package/src/routes/admin/upload/route.ts +55 -0
- package/src/routes/admin/users/[id]/route.ts +24 -0
- package/src/routes/admin/users/route.ts +24 -0
- package/src/routes/admin/webhooks/[id]/route.ts +38 -0
- package/src/routes/admin/webhooks/route.ts +26 -0
- package/src/routes/cron/publish-scheduled.ts +37 -0
- package/src/routes/setup/admin.ts +19 -0
- package/src/schema/admin-ui.ts +18 -0
- package/src/schema/auth.ts +49 -0
- package/src/schema/developer-tools.ts +44 -0
- package/src/schema/helpers.ts +35 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/locales.ts +13 -0
- package/src/schema/media.ts +22 -0
- package/src/schema/rbac.ts +11 -0
- package/src/schema/settings.ts +28 -0
- package/src/secrets.ts +26 -0
- package/src/seo.ts +69 -0
- package/src/storage/index.ts +20 -0
- package/src/storage/local.ts +27 -0
- package/src/storage/s3-compatible.ts +31 -0
- package/src/storage/types.ts +8 -0
- package/src/storage/vercel-blob.ts +17 -0
- package/src/useBodyScrollLock.ts +12 -0
- package/src/useLocalStorageState.ts +26 -0
- package/src/webhook-events.ts +12 -0
- package/src/webhooks.ts +30 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ilike, asc } from 'drizzle-orm'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { requireAction } from '../../../../../permissions'
|
|
4
|
+
import { getCollection } from '../../../../../collections/registry'
|
|
5
|
+
import { collectionColumn, findRow, idColumn, selectRows } from '../../../../../collections/query'
|
|
6
|
+
|
|
7
|
+
const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
8
|
+
const notFound = () => NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
9
|
+
|
|
10
|
+
export async function GET(request: Request, { params }: { params: Promise<{ collection: string }> }) {
|
|
11
|
+
const { collection } = await params
|
|
12
|
+
const config = getCollection(collection)
|
|
13
|
+
if (!config) return notFound()
|
|
14
|
+
const result = await requireAction(config.slug, 'read')
|
|
15
|
+
if (!result) return unauthorized()
|
|
16
|
+
|
|
17
|
+
const url = new URL(request.url)
|
|
18
|
+
const q = url.searchParams.get('q')?.trim().slice(0, 100) || ''
|
|
19
|
+
// Comma-separated — a `relation.hasMany` field's currently-selected ids can be more than one.
|
|
20
|
+
const includeIds = (url.searchParams.get('include')?.trim() || '').split(',').map((id) => id.trim()).filter(Boolean)
|
|
21
|
+
|
|
22
|
+
const titleColumn = collectionColumn(config.slug, config.titleField) ?? idColumn(config.slug)
|
|
23
|
+
const rows = await selectRows(config.slug, {
|
|
24
|
+
where: q ? ilike(titleColumn, `%${q}%`) : undefined,
|
|
25
|
+
orderBy: [asc(titleColumn)],
|
|
26
|
+
limit: 200,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const options = rows.map((row) => ({ value: String(row.id), label: String(row[config.titleField] ?? row.id) }))
|
|
30
|
+
|
|
31
|
+
for (const id of includeIds) {
|
|
32
|
+
if (options.some((option) => option.value === id)) continue
|
|
33
|
+
const row = await findRow(config.slug, id)
|
|
34
|
+
if (row) options.unshift({ value: String(row.id), label: String(row[config.titleField] ?? row.id) })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return NextResponse.json(options)
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { requireAction } from '../../../../../permissions'
|
|
4
|
+
import { getCollection } from '../../../../../collections/registry'
|
|
5
|
+
import { applyOrder } from '../../../../../collections/query'
|
|
6
|
+
|
|
7
|
+
const schema = z.object({ ids: z.array(z.string().min(1).max(200)).min(1).max(500) })
|
|
8
|
+
|
|
9
|
+
export async function PATCH(request: Request, { params }: { params: Promise<{ collection: string }> }) {
|
|
10
|
+
const { collection } = await params
|
|
11
|
+
const config = getCollection(collection)
|
|
12
|
+
if (!config?.workflow?.sortOrder) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
13
|
+
const result = await requireAction(config.slug, 'update')
|
|
14
|
+
if (!result) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
15
|
+
const parsed = schema.safeParse(await request.json())
|
|
16
|
+
if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
|
|
17
|
+
await applyOrder(config.slug, parsed.data.ids)
|
|
18
|
+
return NextResponse.json({ ok: true })
|
|
19
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { requireAction } from '../../../../permissions'
|
|
3
|
+
import { applyFieldPermissions, blankFieldValues, redactFieldsForRead } from '../../../../content-types'
|
|
4
|
+
import { getCollection, relationFields } from '../../../../collections/registry'
|
|
5
|
+
import { buildOrderBy, buildWhere } from '../../../../collections/filters'
|
|
6
|
+
import { countRows, findInvalidRelation, insertRow, isUniqueViolation, labelsForIds, nextSortOrder, selectRows } from '../../../../collections/query'
|
|
7
|
+
import { resolveJoinFieldsForList } from '../../../../collections/join'
|
|
8
|
+
import { parseCollectionBody, type ParseMode } from '../../../../collections/validation'
|
|
9
|
+
import { creationWorkflowValues, entryRequiresApproval, fireWebhook } from '../../../../collections/workflow'
|
|
10
|
+
import { applySlugFallbacks } from '../../../../collections/values'
|
|
11
|
+
import { parseListQuery } from '../../../../listQuery'
|
|
12
|
+
import { getLocaleConfig } from '../../../../locale-settings'
|
|
13
|
+
|
|
14
|
+
const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
15
|
+
const notFound = () => NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
16
|
+
|
|
17
|
+
export async function GET(request: Request, { params }: { params: Promise<{ collection: string }> }) {
|
|
18
|
+
const { collection } = await params
|
|
19
|
+
const config = getCollection(collection)
|
|
20
|
+
if (!config) return notFound()
|
|
21
|
+
const result = await requireAction(config.slug, 'read')
|
|
22
|
+
if (!result) return unauthorized()
|
|
23
|
+
|
|
24
|
+
const query = parseListQuery(request.url, config.workflow?.sortOrder ? 'sortOrder' : config.defaultSort.field)
|
|
25
|
+
const where = buildWhere(config, query)
|
|
26
|
+
const orderBy = buildOrderBy(config, query)
|
|
27
|
+
|
|
28
|
+
const total = await countRows(config.slug, where)
|
|
29
|
+
const rows =
|
|
30
|
+
query.pageSize === 'all'
|
|
31
|
+
? await selectRows(config.slug, { where, orderBy })
|
|
32
|
+
: await selectRows(config.slug, { where, orderBy, limit: query.pageSize, offset: (query.page - 1) * query.pageSize })
|
|
33
|
+
const allowedFields = result.allowedFields(config.slug)
|
|
34
|
+
const withJoins = await resolveJoinFieldsForList(config, rows)
|
|
35
|
+
const items = withJoins.map((row) => redactFieldsForRead(config.slug, row, allowedFields))
|
|
36
|
+
|
|
37
|
+
const relationLabels: Record<string, Record<string, string>> = {}
|
|
38
|
+
for (const field of relationFields(config)) {
|
|
39
|
+
const target = getCollection(field.relationTo)!
|
|
40
|
+
const ids = [...new Set(items.flatMap((item) => {
|
|
41
|
+
const value = item[field.key]
|
|
42
|
+
return field.hasMany
|
|
43
|
+
? (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string' && entry !== '') : [])
|
|
44
|
+
: (typeof value === 'string' && value ? [value] : [])
|
|
45
|
+
}))]
|
|
46
|
+
relationLabels[field.key] = await labelsForIds(target.slug, ids, target.titleField)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return NextResponse.json({ items, total, relationLabels })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function POST(request: Request, { params }: { params: Promise<{ collection: string }> }) {
|
|
53
|
+
const { collection } = await params
|
|
54
|
+
const config = getCollection(collection)
|
|
55
|
+
if (!config) return notFound()
|
|
56
|
+
const result = await requireAction(config.slug, 'create')
|
|
57
|
+
if (!result) return unauthorized()
|
|
58
|
+
|
|
59
|
+
const body = await request.json()
|
|
60
|
+
const workflow = config.workflow
|
|
61
|
+
const wantsPublished = workflow?.publish ? Boolean(body.published) : false
|
|
62
|
+
if (wantsPublished && !result.can(config.slug, 'publish')) return unauthorized()
|
|
63
|
+
if (wantsPublished && await entryRequiresApproval(config)) return NextResponse.json({ error: 'This item has not been approved yet — publishing requires approval.' }, { status: 403 })
|
|
64
|
+
|
|
65
|
+
const mode: ParseMode = workflow?.publish ? (wantsPublished ? 'live' : 'draft') : 'live'
|
|
66
|
+
const parsed = parseCollectionBody(config, body, mode, result.allowedFields(config.slug), await getLocaleConfig())
|
|
67
|
+
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 })
|
|
68
|
+
const id = typeof body.id === 'string' && body.id.trim() ? body.id.trim() : crypto.randomUUID()
|
|
69
|
+
const data = applySlugFallbacks(config, applyFieldPermissions(config.slug, parsed.data, result.allowedFields(config.slug), blankFieldValues[config.slug]), id)
|
|
70
|
+
|
|
71
|
+
const relationError = await findInvalidRelation(config, data)
|
|
72
|
+
if (relationError) return NextResponse.json({ error: relationError }, { status: 400 })
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const sortOrder = workflow?.sortOrder ? await nextSortOrder(config.slug) : 0
|
|
76
|
+
const row = await insertRow(config.slug, {
|
|
77
|
+
...data,
|
|
78
|
+
id,
|
|
79
|
+
...creationWorkflowValues(config, result.session.user.id, wantsPublished, sortOrder),
|
|
80
|
+
...(workflow?.audit && typeof body.ownerId === 'string' && body.ownerId ? { ownerId: body.ownerId } : {}),
|
|
81
|
+
})
|
|
82
|
+
fireWebhook(config, 'created', row)
|
|
83
|
+
return NextResponse.json(row, { status: 201 })
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (isUniqueViolation(error)) return NextResponse.json({ error: 'A record with one of those unique values already exists.' }, { status: 409 })
|
|
86
|
+
throw error
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { getSessionWithPermissions } from '../../../permissions'
|
|
3
|
+
import { collectionSlugs, getCollection } from '../../../collections/registry'
|
|
4
|
+
|
|
5
|
+
export async function GET() {
|
|
6
|
+
const result = await getSessionWithPermissions()
|
|
7
|
+
if (!result) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
8
|
+
return NextResponse.json(
|
|
9
|
+
collectionSlugs
|
|
10
|
+
.filter((slug) => result.can(slug, 'read'))
|
|
11
|
+
.map((slug) => ({ slug, label: getCollection(slug)!.label })),
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { defaultContentView, isContentViewType, normalizeContentView } from '../../../../collections/content-view'
|
|
4
|
+
import { db } from '../../../../db'
|
|
5
|
+
import { contentViews } from '../../../../schema'
|
|
6
|
+
import { getSessionWithPermissions, requireSystem } from '../../../../permissions'
|
|
7
|
+
|
|
8
|
+
function typeFrom(value: string): string | null { return isContentViewType(value) ? value : null }
|
|
9
|
+
|
|
10
|
+
export async function GET(_: Request, { params }: { params: Promise<{ type: string }> }) {
|
|
11
|
+
if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
12
|
+
const type = typeFrom((await params).type)
|
|
13
|
+
if (!type) return NextResponse.json({ error: 'Unknown content type.' }, { status: 404 })
|
|
14
|
+
const [stored] = await db.select().from(contentViews).where(eq(contentViews.contentType, type)).limit(1)
|
|
15
|
+
return NextResponse.json(normalizeContentView(type, stored?.config || defaultContentView(type)))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function PUT(request: Request, { params }: { params: Promise<{ type: string }> }) {
|
|
19
|
+
if (!await requireSystem('views.manage')) return NextResponse.json({ error: 'Only administrators can change editor views.' }, { status: 403 })
|
|
20
|
+
const type = typeFrom((await params).type)
|
|
21
|
+
if (!type) return NextResponse.json({ error: 'Unknown content type.' }, { status: 404 })
|
|
22
|
+
const config = normalizeContentView(type, await request.json())
|
|
23
|
+
const [stored] = await db.insert(contentViews).values({ contentType: type, config }).onConflictDoUpdate({ target: contentViews.contentType, set: { config, updatedAt: new Date() } }).returning()
|
|
24
|
+
return NextResponse.json(stored.config)
|
|
25
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { db } from '../../../../db'
|
|
3
|
+
import { collectionTable } from '../../../../collections/query'
|
|
4
|
+
import { getDashboardSources } from '../../../../dashboard'
|
|
5
|
+
import { getSessionWithPermissions } from '../../../../permissions'
|
|
6
|
+
import type { DashboardWidget } from '../../../../dashboard'
|
|
7
|
+
|
|
8
|
+
const numericFields = new Set(['sortOrder'])
|
|
9
|
+
|
|
10
|
+
export async function POST(request: Request) {
|
|
11
|
+
const result = await getSessionWithPermissions()
|
|
12
|
+
if (!result?.can('settings', 'read')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
13
|
+
const widget = await request.json() as DashboardWidget
|
|
14
|
+
const source = getDashboardSources().find((item) => item.value === widget.source)
|
|
15
|
+
if (!source) return NextResponse.json({ error: 'Unknown dashboard source' }, { status: 400 })
|
|
16
|
+
const allowedFields = new Set(source.fields)
|
|
17
|
+
const table = collectionTable(widget.source)
|
|
18
|
+
const rows = await db.select().from(table)
|
|
19
|
+
const groupBy = widget.groupBy && allowedFields.has(widget.groupBy) ? widget.groupBy : undefined
|
|
20
|
+
const values = rows as Array<Record<string, unknown>>
|
|
21
|
+
if (widget.metric === 'count' && !groupBy) return NextResponse.json({ value: values.length })
|
|
22
|
+
if ((widget.metric === 'sum' || widget.metric === 'avg') && widget.field && numericFields.has(widget.field)) {
|
|
23
|
+
const numbers = values.map((row) => Number(row[widget.field!])).filter(Number.isFinite)
|
|
24
|
+
const total = numbers.reduce((sum, value) => sum + value, 0)
|
|
25
|
+
return NextResponse.json({ value: widget.metric === 'avg' ? (numbers.length ? total / numbers.length : 0) : total })
|
|
26
|
+
}
|
|
27
|
+
if ((widget.metric === 'true' || widget.metric === 'false') && widget.field && allowedFields.has(widget.field)) return NextResponse.json({ value: values.filter((row) => Boolean(row[widget.field!]) === (widget.metric === 'true')).length })
|
|
28
|
+
if (groupBy) {
|
|
29
|
+
const counts = new Map<string, number>()
|
|
30
|
+
for (const row of values) {
|
|
31
|
+
const key = String(row[groupBy] ?? 'Unknown')
|
|
32
|
+
const increment = widget.metric === 'count' ? 1 : widget.metric === 'true' || widget.metric === 'false' ? (Boolean(row[widget.field ?? groupBy]) === (widget.metric === 'true') ? 1 : 0) : widget.field && numericFields.has(widget.field) ? Number(row[widget.field]) || 0 : 0
|
|
33
|
+
counts.set(key, (counts.get(key) ?? 0) + increment)
|
|
34
|
+
}
|
|
35
|
+
const data = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, widget.limit).map(([name, value]) => ({ name, value }))
|
|
36
|
+
return NextResponse.json({ data })
|
|
37
|
+
}
|
|
38
|
+
return NextResponse.json({ value: values.length })
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { db } from '../../../db'
|
|
5
|
+
import { dashboards } from '../../../schema'
|
|
6
|
+
import { getSessionWithPermissions } from '../../../permissions'
|
|
7
|
+
import { defaultDashboard, type DashboardConfig } from '../../../dashboard'
|
|
8
|
+
|
|
9
|
+
const widget = z.object({ id: z.string().min(1).max(80), title: z.string().min(1).max(100), source: z.enum(['services', 'projects', 'blog']), metric: z.enum(['count', 'sum', 'avg', 'true', 'false']), field: z.string().max(60).optional(), groupBy: z.string().max(60).optional(), visual: z.enum(['number', 'bar', 'line', 'donut', 'table']), limit: z.number().int().min(1).max(20), size: z.enum(['small', 'medium', 'large']).default('medium') })
|
|
10
|
+
const configSchema = z.object({ widgets: z.array(widget).max(30) })
|
|
11
|
+
|
|
12
|
+
// Reuses the 'settings' permission as the Dashboard builder's access gate — there's no single
|
|
13
|
+
// type to ride on for this anymore (see collections/types.ts's SingleSlug comment).
|
|
14
|
+
async function access() { const result = await getSessionWithPermissions(); return result?.can('settings', 'read') ? result : null }
|
|
15
|
+
|
|
16
|
+
export async function GET() {
|
|
17
|
+
if (!await access()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
18
|
+
const [dashboard] = await db.select().from(dashboards).where(eq(dashboards.id, 'main')).limit(1)
|
|
19
|
+
const config = (dashboard?.config as DashboardConfig | undefined) ?? defaultDashboard
|
|
20
|
+
return NextResponse.json({ widgets: config.widgets.map((widget) => ({ ...widget, size: widget.size ?? 'medium' })) })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function PUT(request: Request) {
|
|
24
|
+
const result = await access()
|
|
25
|
+
if (!result || !result.can('settings', 'update')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
26
|
+
const parsed = configSchema.safeParse(await request.json())
|
|
27
|
+
if (!parsed.success) return NextResponse.json({ error: 'Invalid dashboard configuration.' }, { status: 400 })
|
|
28
|
+
const [dashboard] = await db.insert(dashboards).values({ id: 'main', config: parsed.data, createdBy: result.session.user.id }).onConflictDoUpdate({ target: dashboards.id, set: { config: parsed.data, updatedAt: new Date() } }).returning()
|
|
29
|
+
return NextResponse.json(dashboard.config)
|
|
30
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { db } from '../../../db'
|
|
5
|
+
import { emailSettings } from '../../../schema'
|
|
6
|
+
import { encryptSecret } from '../../../secrets'
|
|
7
|
+
import { requireSystem } from '../../../permissions'
|
|
8
|
+
import { EMAIL_SECRET_DOMAIN, isEmailConfigured } from '../../../email'
|
|
9
|
+
|
|
10
|
+
export async function GET() {
|
|
11
|
+
if (!await requireSystem('developers.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
12
|
+
const [settings] = await db.select().from(emailSettings).where(eq(emailSettings.id, 'default')).limit(1)
|
|
13
|
+
return NextResponse.json({
|
|
14
|
+
host: settings?.host ?? '',
|
|
15
|
+
port: settings?.port ?? 587,
|
|
16
|
+
secure: settings?.secure ?? true,
|
|
17
|
+
username: settings?.username ?? '',
|
|
18
|
+
fromAddress: settings?.fromAddress ?? '',
|
|
19
|
+
fromName: settings?.fromName ?? '',
|
|
20
|
+
configured: isEmailConfigured(settings ?? null),
|
|
21
|
+
hasPassword: Boolean(settings?.encryptedPassword),
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const schema = z.object({
|
|
26
|
+
host: z.string().trim().max(200),
|
|
27
|
+
port: z.number().int().min(1).max(65535),
|
|
28
|
+
secure: z.boolean(),
|
|
29
|
+
username: z.string().trim().max(200).optional().default(''),
|
|
30
|
+
password: z.string().max(500).optional(),
|
|
31
|
+
fromAddress: z.string().trim().email().or(z.literal('')),
|
|
32
|
+
fromName: z.string().trim().max(120).optional().default(''),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export async function PUT(request: Request) {
|
|
36
|
+
if (!await requireSystem('developers.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
37
|
+
const parsed = schema.safeParse(await request.json())
|
|
38
|
+
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((issue) => `${issue.path.join(' ')}: ${issue.message}`).join(' · ') }, { status: 400 })
|
|
39
|
+
const { password, ...rest } = parsed.data
|
|
40
|
+
const [existing] = await db.select().from(emailSettings).where(eq(emailSettings.id, 'default')).limit(1)
|
|
41
|
+
const encryptedPassword = password ? encryptSecret(password, EMAIL_SECRET_DOMAIN) : existing?.encryptedPassword ?? null
|
|
42
|
+
const data = { ...rest, username: rest.username || null, fromAddress: rest.fromAddress || null, fromName: rest.fromName || null, encryptedPassword, updatedAt: new Date() }
|
|
43
|
+
const [settings] = await db.insert(emailSettings).values({ id: 'default', ...data }).onConflictDoUpdate({ target: emailSettings.id, set: data }).returning()
|
|
44
|
+
return NextResponse.json({
|
|
45
|
+
host: settings.host ?? '', port: settings.port ?? 587, secure: settings.secure, username: settings.username ?? '',
|
|
46
|
+
fromAddress: settings.fromAddress ?? '', fromName: settings.fromName ?? '',
|
|
47
|
+
configured: isEmailConfigured(settings), hasPassword: Boolean(settings.encryptedPassword),
|
|
48
|
+
})
|
|
49
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { requireSystem } from '../../../../permissions'
|
|
4
|
+
import { sendEmail } from '../../../../email'
|
|
5
|
+
|
|
6
|
+
const schema = z.object({ to: z.string().trim().email() })
|
|
7
|
+
|
|
8
|
+
export async function POST(request: Request) {
|
|
9
|
+
if (!await requireSystem('developers.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
10
|
+
const parsed = schema.safeParse(await request.json())
|
|
11
|
+
if (!parsed.success) return NextResponse.json({ error: 'Enter a valid email address.' }, { status: 400 })
|
|
12
|
+
try {
|
|
13
|
+
await sendEmail({ to: parsed.data.to, subject: 'Test email from The Usual CMS', html: '<p>This is a test email from your CMS Email settings. If you received this, your SMTP configuration works.</p>' })
|
|
14
|
+
return NextResponse.json({ ok: true })
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return NextResponse.json({ error: error instanceof Error ? error.message : 'Could not send test email.' }, { status: 400 })
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { asc, eq } from 'drizzle-orm'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { db } from '../../../../db'
|
|
5
|
+
import { cmsLocales } from '../../../../schema'
|
|
6
|
+
import { requireSystem } from '../../../../permissions'
|
|
7
|
+
|
|
8
|
+
const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
9
|
+
const notFound = () => NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
10
|
+
|
|
11
|
+
const updateSchema = z.object({ label: z.string().trim().min(1).max(60), isDefault: z.boolean() })
|
|
12
|
+
|
|
13
|
+
export async function PUT(request: Request, { params }: { params: Promise<{ code: string }> }) {
|
|
14
|
+
if (!await requireSystem('locales.manage')) return unauthorized()
|
|
15
|
+
const { code } = await params
|
|
16
|
+
const parsed = updateSchema.safeParse(await request.json())
|
|
17
|
+
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((issue) => `${issue.path.join(' ')}: ${issue.message}`).join(' · ') }, { status: 400 })
|
|
18
|
+
|
|
19
|
+
if (parsed.data.isDefault) await db.update(cmsLocales).set({ isDefault: false }) // exactly one default
|
|
20
|
+
const [row] = await db.update(cmsLocales).set({ label: parsed.data.label, isDefault: parsed.data.isDefault }).where(eq(cmsLocales.code, code)).returning()
|
|
21
|
+
if (!row) return notFound()
|
|
22
|
+
return NextResponse.json(row)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function DELETE(_request: Request, { params }: { params: Promise<{ code: string }> }) {
|
|
26
|
+
if (!await requireSystem('locales.manage')) return unauthorized()
|
|
27
|
+
const { code } = await params
|
|
28
|
+
const [deleted] = await db.delete(cmsLocales).where(eq(cmsLocales.code, code)).returning()
|
|
29
|
+
if (!deleted) return notFound()
|
|
30
|
+
// If the deleted locale was the default, promote whichever remains first (by sortOrder) — a
|
|
31
|
+
// `localized` field with zero locales, or with no default at all, has nowhere to store its
|
|
32
|
+
// required-locale value, so this table should never end up in that state via the admin UI.
|
|
33
|
+
if (deleted.isDefault) {
|
|
34
|
+
const [next] = await db.select().from(cmsLocales).orderBy(asc(cmsLocales.sortOrder)).limit(1)
|
|
35
|
+
if (next) await db.update(cmsLocales).set({ isDefault: true }).where(eq(cmsLocales.code, next.code))
|
|
36
|
+
}
|
|
37
|
+
return NextResponse.json({ ok: true })
|
|
38
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { db } from '../../../../db'
|
|
5
|
+
import { cmsLocales } from '../../../../schema'
|
|
6
|
+
import { requireSystem } from '../../../../permissions'
|
|
7
|
+
|
|
8
|
+
const schema = z.object({ codes: z.array(z.string().min(1).max(10)).min(1).max(50) })
|
|
9
|
+
|
|
10
|
+
export async function PATCH(request: Request) {
|
|
11
|
+
if (!await requireSystem('locales.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
12
|
+
const parsed = schema.safeParse(await request.json())
|
|
13
|
+
if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
|
|
14
|
+
await db.transaction(async (tx: any) => {
|
|
15
|
+
for (const [index, code] of parsed.data.codes.entries()) await tx.update(cmsLocales).set({ sortOrder: index }).where(eq(cmsLocales.code, code))
|
|
16
|
+
})
|
|
17
|
+
return NextResponse.json({ ok: true })
|
|
18
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { asc } from 'drizzle-orm'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { db } from '../../../db'
|
|
5
|
+
import { cmsLocales } from '../../../schema'
|
|
6
|
+
import { getSessionWithPermissions, requireSystem } from '../../../permissions'
|
|
7
|
+
|
|
8
|
+
const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
9
|
+
const codePattern = /^[a-z]{2}(-[A-Z]{2})?$/ // 'id', 'en', or 'en-US' style
|
|
10
|
+
|
|
11
|
+
// GET is readable by any signed-in admin user (not gated behind 'locales.manage') — every editor
|
|
12
|
+
// working on a `localized` field needs this list to render its locale tabs, not just whoever
|
|
13
|
+
// manages the list itself.
|
|
14
|
+
export async function GET() {
|
|
15
|
+
if (!await getSessionWithPermissions()) return unauthorized()
|
|
16
|
+
const rows = await db.select().from(cmsLocales).orderBy(asc(cmsLocales.sortOrder))
|
|
17
|
+
return NextResponse.json(rows)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const createSchema = z.object({
|
|
21
|
+
code: z.string().regex(codePattern, "Use a short code like 'id', 'en', or 'en-US'.").max(10),
|
|
22
|
+
label: z.string().trim().min(1).max(60),
|
|
23
|
+
isDefault: z.boolean().default(false),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export async function POST(request: Request) {
|
|
27
|
+
if (!await requireSystem('locales.manage')) return unauthorized()
|
|
28
|
+
const parsed = createSchema.safeParse(await request.json())
|
|
29
|
+
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues.map((issue) => `${issue.path.join(' ')}: ${issue.message}`).join(' · ') }, { status: 400 })
|
|
30
|
+
|
|
31
|
+
const existing = await db.select().from(cmsLocales)
|
|
32
|
+
if (parsed.data.isDefault || !existing.length) {
|
|
33
|
+
// Exactly one default — demoting the others is cheap enough to just always do it here
|
|
34
|
+
// instead of a separate "set default" endpoint.
|
|
35
|
+
if (existing.some((row: any) => row.isDefault)) await db.update(cmsLocales).set({ isDefault: false })
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const [row] = await db.insert(cmsLocales).values({
|
|
39
|
+
code: parsed.data.code,
|
|
40
|
+
label: parsed.data.label,
|
|
41
|
+
isDefault: parsed.data.isDefault || !existing.length,
|
|
42
|
+
sortOrder: existing.length,
|
|
43
|
+
}).returning()
|
|
44
|
+
return NextResponse.json(row, { status: 201 })
|
|
45
|
+
} catch { return NextResponse.json({ error: 'A locale with that code already exists.' }, { status: 409 }) }
|
|
46
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { db } from '../../../../../db'
|
|
4
|
+
import { media } from '../../../../../schema'
|
|
5
|
+
import { getSessionWithPermissions } from '../../../../../permissions'
|
|
6
|
+
|
|
7
|
+
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
8
|
+
if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
9
|
+
const { id } = await params
|
|
10
|
+
const [row] = await db.update(media).set({ deletedAt: null }).where(eq(media.id, id)).returning()
|
|
11
|
+
if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
12
|
+
return NextResponse.json(row)
|
|
13
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { db } from '../../../../db'
|
|
5
|
+
import { media, mediaFolders } from '../../../../schema'
|
|
6
|
+
import { getSessionWithPermissions } from '../../../../permissions'
|
|
7
|
+
import { removeUploadedFile } from '../../../../media-storage'
|
|
8
|
+
|
|
9
|
+
export const runtime = 'nodejs'
|
|
10
|
+
|
|
11
|
+
const schema = z.object({
|
|
12
|
+
starred: z.boolean().optional(),
|
|
13
|
+
filename: z.string().trim().min(1).max(200).optional(),
|
|
14
|
+
folder: z.string().optional(),
|
|
15
|
+
}).refine((data) => Object.keys(data).length > 0, { message: 'Nothing to update.' })
|
|
16
|
+
|
|
17
|
+
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
18
|
+
if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
19
|
+
const { id } = await params
|
|
20
|
+
const parsed = schema.safeParse(await request.json())
|
|
21
|
+
if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
|
|
22
|
+
const [existing] = await db.select().from(media).where(eq(media.id, id))
|
|
23
|
+
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
24
|
+
|
|
25
|
+
const updates: Partial<typeof media.$inferInsert> = {}
|
|
26
|
+
if (parsed.data.starred !== undefined) updates.starred = parsed.data.starred
|
|
27
|
+
if (parsed.data.filename !== undefined) updates.filename = parsed.data.filename
|
|
28
|
+
// Folder is pure organization — the file's URL never changes when it moves.
|
|
29
|
+
if (parsed.data.folder !== undefined && parsed.data.folder !== existing.folder) {
|
|
30
|
+
if (parsed.data.folder !== 'uploads') {
|
|
31
|
+
const [target] = await db.select().from(mediaFolders).where(eq(mediaFolders.slug, parsed.data.folder))
|
|
32
|
+
if (!target) return NextResponse.json({ error: 'Folder not found' }, { status: 400 })
|
|
33
|
+
}
|
|
34
|
+
updates.folder = parsed.data.folder
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [row] = await db.update(media).set(updates).where(eq(media.id, id)).returning()
|
|
38
|
+
return NextResponse.json(row)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
42
|
+
if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
43
|
+
const { id } = await params
|
|
44
|
+
const permanent = new URL(request.url).searchParams.get('permanent') === '1'
|
|
45
|
+
|
|
46
|
+
if (permanent) {
|
|
47
|
+
const [row] = await db.delete(media).where(eq(media.id, id)).returning()
|
|
48
|
+
if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
49
|
+
await removeUploadedFile(row.url)
|
|
50
|
+
return NextResponse.json({ ok: true })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const [row] = await db.update(media).set({ deletedAt: new Date() }).where(eq(media.id, id)).returning()
|
|
54
|
+
if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
55
|
+
return NextResponse.json(row)
|
|
56
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { eq, inArray } from 'drizzle-orm'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { db } from '../../../../db'
|
|
5
|
+
import { media, mediaFolders } from '../../../../schema'
|
|
6
|
+
import { getSessionWithPermissions } from '../../../../permissions'
|
|
7
|
+
import { removeUploadedFile } from '../../../../media-storage'
|
|
8
|
+
|
|
9
|
+
export const runtime = 'nodejs'
|
|
10
|
+
|
|
11
|
+
const schema = z.object({
|
|
12
|
+
ids: z.array(z.string()).min(1),
|
|
13
|
+
action: z.enum(['restore', 'delete', 'trash', 'move']),
|
|
14
|
+
folder: z.string().optional(),
|
|
15
|
+
}).refine((data) => data.action !== 'move' || !!data.folder, { message: 'A target folder is required to move items.' })
|
|
16
|
+
|
|
17
|
+
export async function POST(request: Request) {
|
|
18
|
+
if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
19
|
+
const parsed = schema.safeParse(await request.json())
|
|
20
|
+
if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
|
|
21
|
+
const { ids, action, folder } = parsed.data
|
|
22
|
+
|
|
23
|
+
if (action === 'restore') {
|
|
24
|
+
await db.update(media).set({ deletedAt: null }).where(inArray(media.id, ids))
|
|
25
|
+
return NextResponse.json({ ok: true, count: ids.length })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (action === 'trash') {
|
|
29
|
+
await db.update(media).set({ deletedAt: new Date() }).where(inArray(media.id, ids))
|
|
30
|
+
return NextResponse.json({ ok: true, count: ids.length })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (action === 'move') {
|
|
34
|
+
// Folder is pure organization — moving never touches the file's URL.
|
|
35
|
+
if (folder !== 'uploads') {
|
|
36
|
+
const [target] = await db.select().from(mediaFolders).where(eq(mediaFolders.slug, folder!))
|
|
37
|
+
if (!target) return NextResponse.json({ error: 'Folder not found' }, { status: 400 })
|
|
38
|
+
}
|
|
39
|
+
await db.update(media).set({ folder }).where(inArray(media.id, ids))
|
|
40
|
+
return NextResponse.json({ ok: true, count: ids.length })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rows = await db.delete(media).where(inArray(media.id, ids)).returning()
|
|
44
|
+
await Promise.all(rows.map((row: any) => removeUploadedFile(row.url)))
|
|
45
|
+
return NextResponse.json({ ok: true, count: rows.length })
|
|
46
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { db } from '../../../../../db'
|
|
5
|
+
import { media, mediaFolders } from '../../../../../schema'
|
|
6
|
+
import { getSessionWithPermissions } from '../../../../../permissions'
|
|
7
|
+
|
|
8
|
+
const patchSchema = z.object({ pinned: z.boolean().optional(), name: z.string().trim().min(1).max(80).optional() })
|
|
9
|
+
|
|
10
|
+
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
11
|
+
if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
12
|
+
const { id } = await params
|
|
13
|
+
const parsed = patchSchema.safeParse(await request.json())
|
|
14
|
+
if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
|
|
15
|
+
if (parsed.data.pinned === undefined && parsed.data.name === undefined) return NextResponse.json({ error: 'Nothing to update.' }, { status: 400 })
|
|
16
|
+
const [row] = await db.update(mediaFolders).set(parsed.data).where(eq(mediaFolders.id, id)).returning()
|
|
17
|
+
if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
18
|
+
return NextResponse.json(row)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
22
|
+
if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
23
|
+
const { id } = await params
|
|
24
|
+
const [folder] = await db.select().from(mediaFolders).where(eq(mediaFolders.id, id))
|
|
25
|
+
if (!folder) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
26
|
+
|
|
27
|
+
// Files inside the folder are kept, not deleted — they move back to Uploads. Folder is pure
|
|
28
|
+
// organization, so this is a plain metadata update; the files' URLs never change.
|
|
29
|
+
await db.transaction(async (tx: any) => {
|
|
30
|
+
await tx.update(media).set({ folder: 'uploads' }).where(eq(media.folder, folder.slug))
|
|
31
|
+
await tx.delete(mediaFolders).where(eq(mediaFolders.id, id))
|
|
32
|
+
})
|
|
33
|
+
return NextResponse.json({ ok: true })
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { and, asc, count as sqlCount, desc, eq, isNotNull, isNull } from 'drizzle-orm'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { db } from '../../../db'
|
|
4
|
+
import { media, mediaFolders } from '../../../schema'
|
|
5
|
+
import { getSessionWithPermissions } from '../../../permissions'
|
|
6
|
+
|
|
7
|
+
const slugify = (value: string) => value.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
|
8
|
+
const RECENT_LIMIT = 60
|
|
9
|
+
|
|
10
|
+
export async function GET(request: Request) {
|
|
11
|
+
if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
12
|
+
const params = new URL(request.url).searchParams
|
|
13
|
+
const view = params.get('view') || 'folder'
|
|
14
|
+
const folder = params.get('folder') || 'uploads'
|
|
15
|
+
const [customFolders, items, counts] = await Promise.all([
|
|
16
|
+
db.select().from(mediaFolders).orderBy(asc(mediaFolders.name)),
|
|
17
|
+
view === 'recent'
|
|
18
|
+
? db.select().from(media).where(isNull(media.deletedAt)).orderBy(desc(media.createdAt)).limit(RECENT_LIMIT)
|
|
19
|
+
: view === 'starred'
|
|
20
|
+
? db.select().from(media).where(and(eq(media.starred, true), isNull(media.deletedAt))).orderBy(desc(media.createdAt))
|
|
21
|
+
: view === 'trash'
|
|
22
|
+
? db.select().from(media).where(isNotNull(media.deletedAt)).orderBy(desc(media.deletedAt))
|
|
23
|
+
: db.select().from(media).where(and(eq(media.folder, folder), isNull(media.deletedAt))).orderBy(desc(media.createdAt)),
|
|
24
|
+
db.select({ folder: media.folder, count: sqlCount() }).from(media).where(isNull(media.deletedAt)).groupBy(media.folder),
|
|
25
|
+
])
|
|
26
|
+
const countByFolder = Object.fromEntries(counts.map((row: any) => [row.folder, Number(row.count)]))
|
|
27
|
+
const folders = [{ id: 'uploads', name: 'Uploads', slug: 'uploads' }, ...customFolders].map((item) => ({ ...item, count: countByFolder[item.slug] ?? 0 }))
|
|
28
|
+
return NextResponse.json({ folders, media: items })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function POST(request: Request) {
|
|
32
|
+
if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
33
|
+
const body = await request.json() as { name?: string }
|
|
34
|
+
const name = body.name?.trim() || ''
|
|
35
|
+
const slug = slugify(name)
|
|
36
|
+
if (!name || !slug) return NextResponse.json({ error: 'Enter a valid folder name.' }, { status: 400 })
|
|
37
|
+
try {
|
|
38
|
+
const [folder] = await db.insert(mediaFolders).values({ id: crypto.randomUUID(), name, slug }).returning()
|
|
39
|
+
return NextResponse.json(folder, { status: 201 })
|
|
40
|
+
} catch { return NextResponse.json({ error: 'That folder already exists.' }, { status: 409 }) }
|
|
41
|
+
}
|