@actuate-media/cms-admin 0.42.2 → 0.43.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.
@@ -87,6 +87,7 @@ interface CollectionApiRow {
87
87
  group: string | null
88
88
  hidden: boolean
89
89
  fieldCount: number
90
+ excludeFromPostsAggregate?: boolean
90
91
  }
91
92
 
92
93
  function isPostsCollection(c: CollectionApiRow): boolean {
@@ -96,7 +97,9 @@ function isPostsCollection(c: CollectionApiRow): boolean {
96
97
  async function fetchPostCollections(): Promise<CollectionApiRow[]> {
97
98
  const res = await cmsApi<CollectionApiRow[]>('/collections')
98
99
  if (res.error || !res.data) return []
99
- return res.data.filter((c) => !c.hidden && isPostsCollection(c))
100
+ // `excludeFromPostsAggregate` mirrors the server-side /posts filter: a
101
+ // post-shaped collection with its own workflow stays out of the Posts UI.
102
+ return res.data.filter((c) => !c.hidden && !c.excludeFromPostsAggregate && isPostsCollection(c))
100
103
  }
101
104
 
102
105
  export async function fetchPostTypes(): Promise<PostType[]> {
@@ -14,13 +14,18 @@ import { useRef, useState } from 'react'
14
14
 
15
15
  import { MediaPickerModal } from '../../components/MediaPickerModal.js'
16
16
  import { SettingsCard, SettingsControlRow, SettingsSubsection } from './components.js'
17
- import type {
18
- BrandAssetField,
19
- SelectedBrandAsset,
20
- UseAppearanceSettings,
17
+ import {
18
+ FAVICON_MIME,
19
+ LOGO_MIME,
20
+ type BrandAssetField,
21
+ type SelectedBrandAsset,
22
+ type UseAppearanceSettings,
21
23
  } from './useAppearanceSettings.js'
22
24
 
23
- const LOGO_ACCEPT = 'image/svg+xml,image/png'
25
+ // Derive the file-input accept strings from the validation allow-lists so the
26
+ // picker and the validator can never drift apart.
27
+ const LOGO_ACCEPT = LOGO_MIME.join(',')
28
+ const FAVICON_ACCEPT = FAVICON_MIME.join(',')
24
29
 
25
30
  function previewSurface(dark: boolean): string {
26
31
  return dark ? 'bg-foreground border-border' : 'bg-muted border-border'
@@ -243,7 +248,7 @@ export function BrandingCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit
243
248
  <div className="space-y-5">
244
249
  <LogoField
245
250
  label="Primary logo"
246
- helper="SVG or PNG, up to 1MB. Best for the public header and full admin sidebar."
251
+ helper="SVG, PNG, WebP, JPEG, GIF, or AVIF, up to 1MB. Best for the public header and full admin sidebar."
247
252
  field="publicPrimaryLogoAssetId"
248
253
  warning={warningFor('publicPrimaryLogoAssetId')}
249
254
  {...logoFieldProps}
@@ -251,11 +256,12 @@ export function BrandingCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit
251
256
 
252
257
  <LogoField
253
258
  label="Favicon / app icon source"
254
- helper="32×32 PNG or SVG. A larger square image is fine — it scales down cleanly."
259
+ helper="32×32 SVG, PNG, or WebP. A larger square image is fine — it scales down cleanly."
255
260
  field="faviconSourceAssetId"
256
261
  square
257
262
  warning={warningFor('faviconSourceAssetId')}
258
263
  {...logoFieldProps}
264
+ accept={FAVICON_ACCEPT}
259
265
  />
260
266
 
261
267
  {/* Advanced logo variants disclosure (Logo mark + Dark mode logo). */}
@@ -84,10 +84,38 @@ export interface SelectedBrandAsset {
84
84
  height?: number
85
85
  }
86
86
 
87
- const LOGO_MIME = ['image/svg+xml', 'image/png']
88
- const FAVICON_MIME = ['image/svg+xml', 'image/png']
87
+ /**
88
+ * Formats accepted for brand logos. Everything here is also validated
89
+ * server-side (allowlist + magic bytes) in cms-core's upload security.
90
+ * Brand uploads are stored as-is (`skipOptimize`), so each format keeps
91
+ * its native fidelity — including WebP, which general media uploads are
92
+ * converted to anyway.
93
+ */
94
+ export const LOGO_MIME = [
95
+ 'image/svg+xml',
96
+ 'image/png',
97
+ 'image/webp',
98
+ 'image/jpeg',
99
+ 'image/gif',
100
+ 'image/avif',
101
+ ]
102
+ /** Favicons need crisp edges at small sizes — vector or lossless/modern raster only. */
103
+ export const FAVICON_MIME = ['image/svg+xml', 'image/png', 'image/webp']
89
104
  const MAX_BYTES = 1024 * 1024 // 1 MB
90
105
 
106
+ const MIME_LABEL: Record<string, string> = {
107
+ 'image/svg+xml': 'SVG',
108
+ 'image/png': 'PNG',
109
+ 'image/webp': 'WebP',
110
+ 'image/jpeg': 'JPEG',
111
+ 'image/gif': 'GIF',
112
+ 'image/avif': 'AVIF',
113
+ }
114
+
115
+ function formatAllowed(mimes: string[]): string {
116
+ return mimes.map((m) => MIME_LABEL[m] ?? m).join(', ')
117
+ }
118
+
91
119
  export interface UseAppearanceSettings {
92
120
  loading: boolean
93
121
  loadError: string | null
@@ -163,19 +191,25 @@ async function detectShape(file: File): Promise<LogoShape> {
163
191
  }
164
192
  return 'unknown'
165
193
  }
166
- return new Promise<LogoShape>((resolve) => {
167
- const url = URL.createObjectURL(file)
168
- const img = new Image()
169
- img.onload = () => {
170
- URL.revokeObjectURL(url)
171
- resolve(classifyLogoShape(img.naturalWidth, img.naturalHeight))
172
- }
173
- img.onerror = () => {
174
- URL.revokeObjectURL(url)
175
- resolve('unknown')
176
- }
177
- img.src = url
178
- })
194
+ // Shape detection only powers advisory warnings — it must never block or
195
+ // fail an upload, so any decode problem resolves to 'unknown'.
196
+ try {
197
+ return await new Promise<LogoShape>((resolve) => {
198
+ const url = URL.createObjectURL(file)
199
+ const img = new Image()
200
+ img.onload = () => {
201
+ URL.revokeObjectURL(url)
202
+ resolve(classifyLogoShape(img.naturalWidth, img.naturalHeight))
203
+ }
204
+ img.onerror = () => {
205
+ URL.revokeObjectURL(url)
206
+ resolve('unknown')
207
+ }
208
+ img.src = url
209
+ })
210
+ } catch {
211
+ return 'unknown'
212
+ }
179
213
  }
180
214
 
181
215
  function assetToCache(
@@ -344,7 +378,7 @@ export function useAppearanceSettings(
344
378
  const isFavicon = field === 'faviconSourceAssetId'
345
379
  const allowed = isFavicon ? FAVICON_MIME : LOGO_MIME
346
380
  if (!allowed.includes(file.type)) {
347
- setSaveError(`Unsupported file type. Allowed: ${allowed.join(', ')}.`)
381
+ setSaveError(`Unsupported file type. Allowed: ${formatAllowed(allowed)}.`)
348
382
  return
349
383
  }
350
384
  if (file.size > MAX_BYTES) {
@@ -357,8 +391,8 @@ export function useAppearanceSettings(
357
391
  const shape = await detectShape(file)
358
392
  const form = new FormData()
359
393
  form.append('file', file)
360
- // Preserve original format (SVG/PNG/ICO fidelity) — brand assets must
361
- // not be transcoded to WebP.
394
+ // Preserve the original format — brand assets must not be transcoded
395
+ // (SVG stays vector, PNG keeps transparency, WebP/AVIF stay as-is).
362
396
  form.append('skipOptimize', 'true')
363
397
  const res = await cmsApi<{ id: string; url?: string; mimeType?: string }>('/media/upload', {
364
398
  method: 'POST',
@@ -388,12 +422,14 @@ export function useAppearanceSettings(
388
422
 
389
423
  const selectAsset = useCallback(
390
424
  (field: BrandAssetField, item: SelectedBrandAsset) => {
391
- // Same allow-list as upload: brand assets must be SVG or PNG, regardless
392
- // of what else lives in the media library.
425
+ // Same allow-list as upload, regardless of what else lives in the
426
+ // media library (which also holds documents, video, etc.).
393
427
  const isFavicon = field === 'faviconSourceAssetId'
394
428
  const allowed = isFavicon ? FAVICON_MIME : LOGO_MIME
395
429
  if (!allowed.includes(item.mimeType)) {
396
- setSaveError(`Unsupported file type. Brand assets must be ${allowed.join(' or ')}.`)
430
+ setSaveError(
431
+ `Unsupported file type. ${isFavicon ? 'Favicons' : 'Brand assets'} must be ${formatAllowed(allowed)}.`,
432
+ )
397
433
  return
398
434
  }
399
435
  setSaveError(null)